Merge remote-tracking branch 'origin/develop' into last-admin-leave-room-warning

This commit is contained in:
David Baker 2024-03-21 11:39:47 +00:00
commit 0fdb300858
2886 changed files with 393845 additions and 234022 deletions

View file

@ -23,4 +23,7 @@ indent_size = 4
trim_trailing_whitespace = true
[*.{yml,yaml}]
indent_size = 2
indent_size = 4
[*.tsx.snap]
trim_trailing_whitespace = false

View file

@ -1,12 +1,9 @@
module.exports = {
plugins: [
"matrix-org",
],
extends: [
"plugin:matrix-org/babel",
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
],
plugins: ["matrix-org"],
extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"],
parserOptions: {
project: ["./tsconfig.json"],
},
env: {
browser: true,
node: true,
@ -19,7 +16,6 @@ module.exports = {
"no-constant-condition": "off",
"prefer-promise-reject-errors": "off",
"no-async-promise-executor": "off",
"quotes": "off",
"no-extra-boolean-cast": "off",
// Bind or arrow functions in props causes performance issues (but we
@ -40,43 +36,125 @@ 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", {
"paths": [{
"name": "matrix-js-sdk",
"message": "Please use matrix-js-sdk/src/matrix instead",
}, {
"name": "matrix-js-sdk/",
"message": "Please use matrix-js-sdk/src/matrix instead",
}, {
"name": "matrix-js-sdk/src",
"message": "Please use matrix-js-sdk/src/matrix instead",
}, {
"name": "matrix-js-sdk/src/",
"message": "Please use matrix-js-sdk/src/matrix instead",
}, {
"name": "matrix-js-sdk/src/index",
"message": "Please use matrix-js-sdk/src/matrix instead",
}, {
"name": "matrix-react-sdk",
"message": "Please use matrix-react-sdk/src/index instead",
}, {
"name": "matrix-react-sdk/",
"message": "Please use matrix-react-sdk/src/index instead",
}],
"patterns": [{
"group": ["matrix-js-sdk/lib", "matrix-js-sdk/lib/", "matrix-js-sdk/lib/**"],
"message": "Please use matrix-js-sdk/src/* instead",
}],
}],
"no-restricted-imports": [
"error",
{
paths: [
{
name: "matrix-js-sdk",
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
name: "matrix-js-sdk/",
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
name: "matrix-js-sdk/src",
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
name: "matrix-js-sdk/src/",
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
name: "matrix-js-sdk/src/index",
message: "Please use matrix-js-sdk/src/matrix instead",
},
{
name: "matrix-react-sdk",
message: "Please use matrix-react-sdk/src/index instead",
},
{
name: "matrix-react-sdk/",
message: "Please use matrix-react-sdk/src/index instead",
},
],
patterns: [
{
group: [
"matrix-js-sdk/src/**",
"!matrix-js-sdk/src/matrix",
"!matrix-js-sdk/src/types",
"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/rendezvous/transports",
"!matrix-js-sdk/src/rendezvous/channels",
"!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-api",
"!matrix-js-sdk/src/crypto-api/verification",
"!matrix-js-sdk/src/crypto",
"!matrix-js-sdk/src/crypto/algorithms",
"!matrix-js-sdk/src/crypto/api",
"!matrix-js-sdk/src/crypto/aes",
"!matrix-js-sdk/src/crypto/backup",
"!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/crypto/verification",
"!matrix-js-sdk/src/crypto/verification/SAS",
"!matrix-js-sdk/src/crypto/verification/QRCode",
"!matrix-js-sdk/src/crypto/verification/request",
"!matrix-js-sdk/src/crypto/verification/request/VerificationRequest",
"!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",
},
],
},
],
// There are too many a11y violations to fix at once
// Turn violated rules off until they are fixed
"jsx-a11y/alt-text": "off",
"jsx-a11y/aria-activedescendant-has-tabindex": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/interactive-supports-focus": "off",
"jsx-a11y/label-has-associated-control": "off",
"jsx-a11y/media-has-caption": "off",
"jsx-a11y/mouse-events-have-key-events": "off",
"jsx-a11y/no-autofocus": "off",
@ -85,25 +163,23 @@ 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",
],
extends: [
"plugin:matrix-org/typescript",
"plugin:matrix-org/react",
],
files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}", "playwright/**/*.ts"],
extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react"],
rules: {
"@typescript-eslint/explicit-function-return-type": [
"error",
{
allowExpressions: true,
},
],
// Things we do that break the ideal style
"prefer-promise-reject-errors": "off",
"quotes": "off",
"no-extra-boolean-cast": "off",
// Remove Babel things manually due to override limitations
@ -117,10 +193,6 @@ module.exports = {
"@typescript-eslint/ban-ts-comment": "off",
// We're okay with assertion errors when we ask for them
"@typescript-eslint/no-non-null-assertion": "off",
// The non-TypeScript rule produces false positives
"func-call-spacing": "off",
"@typescript-eslint/func-call-spacing": ["error"],
},
},
// temporary override for offending icon require files
@ -153,12 +225,41 @@ module.exports = {
"src/components/views/rooms/MessageComposer.tsx",
"src/components/views/rooms/ReplyPreview.tsx",
"src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx",
"src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx"
"src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx",
],
rules: {
"@typescript-eslint/no-var-requires": "off",
},
}
},
{
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/Playwright specific
// Disabled tests are a reality for now but as soon as all of the xits are
// eliminated, we should enforce this.
"jest/no-disabled-tests": "off",
// Also treat "oldBackendOnly" as a test function.
// Used in some crypto tests.
"jest/no-standalone-expect": [
"error",
{
additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly"],
},
],
},
},
{
files: ["playwright/**/*.ts"],
parserOptions: {
project: ["./playwright/tsconfig.json"],
},
},
],
settings: {
react: {
@ -168,7 +269,7 @@ module.exports = {
};
function buildRestrictedPropertiesOptions(properties, message) {
return properties.map(prop => {
return properties.map((prop) => {
let [object, property] = prop.split(".");
if (object === "*") {
object = undefined;

2
.git-blame-ignore-revs Normal file
View file

@ -0,0 +1,2 @@
# prettier
526645c79160ab1ad4b4c3845de27d51263a405e

15
.github/CODEOWNERS vendored
View file

@ -1 +1,14 @@
* @matrix-org/element-web
* @matrix-org/element-web-reviewers
/.github/workflows/** @matrix-org/element-web-team
/package.json @matrix-org/element-web-team
/yarn.lock @matrix-org/element-web-team
/src/SecurityManager.ts @matrix-org/element-crypto-web-reviewers
/test/SecurityManager-test.ts @matrix-org/element-crypto-web-reviewers
/src/async-components/views/dialogs/security/ @matrix-org/element-crypto-web-reviewers
/src/components/views/dialogs/security/ @matrix-org/element-crypto-web-reviewers
/test/components/views/dialogs/security/ @matrix-org/element-crypto-web-reviewers
/src/stores/SetupEncryptionStore.ts @matrix-org/element-crypto-web-reviewers
/test/stores/SetupEncryptionStore-test.ts @matrix-org/element-crypto-web-reviewers
/src/i18n/strings

View file

@ -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))
<!--
If you would like to specify text for the changelog entry other than your PR title, add the following:
Notes: Add super cool feature
Changes in this project also generate changelogs in Element Web. To disable this, use the following:
element-web notes: none
or specify alternative text:
element-web notes: Add super cool feature
-->
- [ ] 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)).

1
.github/release-drafter.yml vendored Normal file
View file

@ -0,0 +1 @@
_extends: matrix-org/matrix-js-sdk

View file

@ -1,6 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"github>matrix-org/renovate-config-element-web"
]
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>matrix-org/renovate-config-element-web"]
}

View file

@ -1,30 +1,30 @@
name: Backport
on:
pull_request_target:
types:
- closed
- labeled
branches:
- develop
pull_request_target:
types:
- closed
- labeled
branches:
- develop
jobs:
backport:
name: Backport
runs-on: ubuntu-latest
# Only react to merged PRs for security reasons.
# See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target.
if: >
github.event.pull_request.merged
&& (
github.event.action == 'closed'
|| (
github.event.action == 'labeled'
&& contains(github.event.label.name, 'backport')
)
)
steps:
- uses: tibdex/backport@v2
with:
labels_template: "<%= JSON.stringify([...labels, 'X-Release-Blocker']) %>"
# We can't use GITHUB_TOKEN here or CI won't run on the new PR
github_token: ${{ secrets.ELEMENT_BOT_TOKEN }}
backport:
name: Backport
runs-on: ubuntu-latest
# Only react to merged PRs for security reasons.
# See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target.
if: >
github.event.pull_request.merged
&& (
github.event.action == 'closed'
|| (
github.event.action == 'labeled'
&& contains(github.event.label.name, 'backport')
)
)
steps:
- uses: tibdex/backport@9565281eda0731b1d20c4025c43339fb0a23812e # v2
with:
labels_template: "<%= JSON.stringify([...labels, 'X-Release-Blocker']) %>"
# We can't use GITHUB_TOKEN here or CI won't run on the new PR
github_token: ${{ secrets.ELEMENT_BOT_TOKEN }}

View file

@ -1,172 +0,0 @@
# Triggers after the layered build has finished, taking the artifact and running cypress on it
name: Cypress End to End Tests
on:
workflow_run:
workflows: [ "Element Web - Build" ]
types:
- completed
jobs:
prepare:
name: Prepare
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
issues: read
statuses: write
pull-requests: read
outputs:
uuid: ${{ steps.uuid.outputs.value }}
pr_id: ${{ steps.prdetails.outputs.pr_id }}
commit_message: ${{ steps.commit.outputs.message }}
commit_author: ${{ steps.commit.outputs.author }}
commit_email: ${{ steps.commit.outputs.email }}
percy_enable: ${{ steps.percy.outputs.value || '1' }}
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before Cypress is done.
- uses: Sibz/github-status-action@v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
context: ${{ github.workflow }} / cypress (${{ github.event.workflow_run.event }} => ${{ github.event_name }})
sha: ${{ github.event.workflow_run.head_sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- id: prdetails
if: github.event.workflow_run.event == 'pull_request'
uses: matrix-org/pr-details-action@v1.2
with:
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
- name: Get commit details
id: commit
if: github.event.workflow_run.event == 'pull_request'
uses: actions/github-script@v5
with:
script: |
const response = await github.rest.git.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: "${{ github.event.workflow_run.head_sha }}",
});
core.setOutput("message", response.data.message);
core.setOutput("author", response.data.author.name);
core.setOutput("email", response.data.author.email);
# Only run Percy when it is demanded or on develop
- name: Disable Percy if not needed
id: percy
if: |
github.event.workflow_run.event == 'pull_request' &&
!contains(fromJSON(steps.prdetails.outputs.data).labels.*.name, 'X-Needs-Percy')
run: echo "::set-output name=value::0"
- name: Generate unique ID 💎
id: uuid
run: echo "::set-output name=value::sha-$GITHUB_SHA-time-$(date +"%s")"
tests:
name: "Run Tests"
needs: prepare
runs-on: ubuntu-latest
permissions:
actions: read
issues: read
pull-requests: read
environment: Cypress
strategy:
fail-fast: false
matrix:
# Run 4 instances in Parallel
runner: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v2
with:
# XXX: We're checking out untrusted code in a secure context
# We need to be careful to not trust anything this code outputs/may do
# We need to check this out to access the cypress tests which are on the head branch
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: 📥 Download artifact
uses: dawidd6/action-download-artifact@v2
with:
workflow: element-build-and-test.yaml
run_id: ${{ github.event.workflow_run.id }}
name: previewbuild
path: webapp
- name: Run Cypress tests
uses: cypress-io/github-action@v4.1.1
with:
# The built-in Electron runner seems to grind to a halt trying
# to run the tests, so use chrome.
browser: chrome
start: npx serve -p 8080 webapp
wait-on: 'http://localhost:8080'
record: true
parallel: true
command-prefix: 'yarn percy exec --parallel --'
ci-build-id: ${{ needs.prepare.outputs.uuid }}
env:
# pass the Dashboard record key as an environment variable
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
# Use existing chromium rather than downloading another
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
# pass GitHub token to allow accurately detecting a build vs a re-run build
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# make Node's os.tmpdir() return something where we actually have permissions
TMPDIR: ${{ runner.temp }}
# tell Cypress more details about the context of this run
COMMIT_INFO_BRANCH: ${{ github.event.workflow_run.head_branch }}
COMMIT_INFO_SHA: ${{ github.event.workflow_run.head_sha }}
COMMIT_INFO_REMOTE: ${{ github.repositoryUrl }}
COMMIT_INFO_MESSAGE: ${{ needs.prepare.outputs.commit_message }}
COMMIT_INFO_AUTHOR: ${{ needs.prepare.outputs.commit_author }}
COMMIT_INFO_EMAIL: ${{ needs.prepare.outputs.commit_email }}
# pass the Percy token as an environment variable
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
PERCY_ENABLE: ${{ needs.prepare.outputs.percy_enable }}
PERCY_BROWSER_EXECUTABLE: /usr/bin/chromium-browser
# tell Percy more details about the context of this run
PERCY_BRANCH: ${{ github.event.workflow_run.head_branch }}
PERCY_COMMIT: ${{ github.event.workflow_run.head_sha }}
PERCY_PULL_REQUEST: ${{ needs.prepare.outputs.pr_id }}
PERCY_PARALLEL_TOTAL: ${{ strategy.job-total }}
PERCY_PARALLEL_NONCE: ${{ needs.prepare.outputs.uuid }}
- name: Upload Artifact
if: failure()
uses: actions/upload-artifact@v2
with:
name: cypress-results
path: |
cypress/screenshots
cypress/videos
cypress/synapselogs
report:
name: Report results
needs: tests
runs-on: ubuntu-latest
if: always()
permissions:
statuses: write
steps:
- uses: Sibz/github-status-action@v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: ${{ needs.tests.result == 'success' && 'success' || 'failure' }}
context: ${{ github.workflow }} / cypress (${{ github.event.workflow_run.event }} => ${{ github.event_name }})
sha: ${{ github.event.workflow_run.head_sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}

View file

@ -3,50 +3,88 @@
# as an artifact and run integration tests.
name: Element Web - Build
on:
pull_request: { }
push:
branches: [ develop, master ]
repository_dispatch:
types: [ upstream-sdk-notify ]
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:
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:
# These must be set for fetchdep.sh to get the right branch
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# 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
steps:
- uses: actions/checkout@v2
build:
name: "Build Element-Web"
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
repository: ${{ inputs.react-sdk-repository || github.repository }}
- uses: actions/setup-node@v3
with:
cache: 'yarn'
- uses: actions/setup-node@v4
with:
cache: "yarn"
- name: Fetch layered build
id: layered_build
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 "::set-output name=VERSION::$VECTOR_SHA-react-$REACT_SHA-js-$JSSDK_SHA"
- 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: 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
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@v2
with:
name: previewbuild
path: element-web/webapp
# We'll only use this in a triggered job, then we're done with it
retention-days: 1
# Record the react-sdk sha so our Playwright tests are from the same sha
- name: Record react-sdk SHA
run: |
git rev-parse HEAD > element-web/webapp/sha
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: previewbuild
path: element-web/webapp
# We'll only use this in a triggered job, then we're done with it
retention-days: 1

191
.github/workflows/end-to-end-tests.yaml vendored Normal file
View file

@ -0,0 +1,191 @@
# Triggers after the layered build has finished, taking the artifact and running Playwright on it
name: End to End Tests
on:
workflow_run:
workflows: ["Element Web - Build"]
types:
- completed
# support calls from other workflows for downstream testing
workflow_call:
inputs:
react-sdk-repository:
type: string
required: true
description: "The name of the github repository to check out and build."
secrets:
ELEMENT_BOT_TOKEN:
required: true
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
jobs:
prepare:
name: Prepare
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
statuses: write
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before the tests are done.
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
context: ${{ github.workflow }} / end-to-end-tests
sha: ${{ github.event.workflow_run.head_sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
tests:
name: "Run Tests ${{ matrix.runner }}/${{ strategy.job-total }}"
needs: prepare
runs-on: ubuntu-latest
permissions:
actions: read
issues: read
pull-requests: read
environment: EndToEndTests
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:
- name: 📥 Download artifact
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: previewbuild
path: webapp
# The workflow_run.head_sha is the sha of the head commit but the element-web was built using a simulated
# merge commit - https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
# so use the sha from the tarball for the checkout of the tests
# to make sure we get a matching set of code and tests.
- name: Grab sha from webapp
id: sha
run: |
echo "sha=$(cat webapp/sha)" >> $GITHUB_OUTPUT
- uses: actions/checkout@v4
with:
# XXX: We're checking out untrusted code in a secure context
# We need to be careful to not trust anything this code outputs/may do
#
# Note that (in the absence of a `react-sdk-repository` input),
# we check out from the default repository, which is (for this workflow) the
# *target* repository for the pull request.
#
ref: ${{ steps.sha.outputs.sha }}
persist-credentials: false
path: matrix-react-sdk
repository: ${{ inputs.react-sdk-repository || github.repository }}
- 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
report:
name: Report results
needs: tests
runs-on: ubuntu-latest
environment: Netlify
if: always()
permissions:
statuses: write
deployments: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
repository: ${{ inputs.react-sdk-repository || github.repository }}
- uses: actions/setup-node@v4
with:
cache: "yarn"
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Download blob reports from GitHub Actions Artifacts
uses: actions/download-artifact@v4
with:
pattern: all-blob-reports-*
path: all-blob-reports
merge-multiple: true
- name: Merge into HTML Report
run: yarn playwright merge-reports --reporter=html,github,./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
uses: actions/upload-artifact@v4
with:
name: html-report--attempt-${{ github.run_attempt }}
path: playwright-report
retention-days: 14
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: ${{ needs.tests.result == 'success' && 'success' || 'failure' }}
context: ${{ github.workflow }} / end-to-end-tests
sha: ${{ github.event.workflow_run.head_sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v3
with:
path: playwright-report
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site_id: ${{ secrets.NETLIFY_SITE_ID }}
desc: Playwright Report
deployment_env: EndToEndTests
prefix: "e2e-"

View file

@ -1,40 +0,0 @@
name: i18n Check
on:
workflow_call: { }
jobs:
check:
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: actions/checkout@v3
- name: "Get modified files"
id: changed_files
if: github.event_name == 'pull_request' && github.event.pull_request.user.login != 'RiotTranslateBot'
uses: tj-actions/changed-files@v19
with:
files: |
src/i18n/strings/*
files_ignore: |
src/i18n/strings/en_EN.json
- name: "Assert only en_EN was modified"
if: |
github.event_name == 'pull_request' &&
github.event.pull_request.user.login != 'RiotTranslateBot' &&
steps.changed_files.outputs.any_modified == 'true'
run: |
echo "Only translation files modified by `yarn i18n` can be committed - other translation files will confuse weblate in unrecoverable ways."
exit 1
- uses: actions/setup-node@v3
with:
cache: 'yarn'
# Does not need branch matching as only analyses this layer
- name: Install Deps
run: "yarn install --pure-lockfile"
- name: i18n Check
run: "yarn run diff-i18n"

View file

@ -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 }}

11
.github/workflows/localazy_upload.yaml vendored Normal file
View file

@ -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 }}

View file

@ -2,70 +2,47 @@
# and uploading it to netlify
name: Upload Preview Build to Netlify
on:
workflow_run:
workflows: [ "Element Web - Build" ]
types:
- completed
workflow_run:
workflows: ["Element Web - Build"]
types:
- completed
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
environment: Netlify
steps:
- name: 📝 Create Deployment
uses: bobheadxi/deployments@v1
id: deployment
with:
step: start
token: ${{ secrets.GITHUB_TOKEN }}
env: Netlify
ref: ${{ github.event.workflow_run.head_sha }}
desc: |
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
Exercise caution. Use test accounts.
deploy:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
environment: Netlify
steps:
- name: 📝 Create Deployment
uses: bobheadxi/deployments@88ce5600046c82542f8246ac287d0a53c461bca3 # v1
id: deployment
with:
step: start
token: ${{ secrets.GITHUB_TOKEN }}
env: Netlify
ref: ${{ github.event.workflow_run.head_sha }}
desc: |
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
Exercise caution. Use test accounts.
- id: prdetails
uses: matrix-org/pr-details-action@v1.2
with:
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
- name: 📥 Download artifact
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: previewbuild
path: webapp
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: 📥 Download artifact
uses: dawidd6/action-download-artifact@v2
with:
workflow: element-build-and-test.yaml
run_id: ${{ github.event.workflow_run.id }}
name: previewbuild
path: webapp
- name: ☁️ Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@v1.2
with:
publish-dir: webapp
deploy-message: "Deploy from GitHub Actions"
# These don't work because we're in workflow_run
enable-pull-request-comment: false
enable-commit-comment: false
alias: pr${{ steps.prdetails.outputs.pr_id }}
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
timeout-minutes: 1
- name: 🚦 Update deployment status
uses: bobheadxi/deployments@v1
if: always()
with:
step: finish
override: false
token: ${{ secrets.GITHUB_TOKEN }}
status: ${{ job.status }}
env: ${{ steps.deployment.outputs.env }}
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
env_url: ${{ steps.netlify.outputs.deploy-url }}
desc: |
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
Exercise caution. Use test accounts.
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v3
with:
path: webapp
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site_id: ${{ secrets.NETLIFY_SITE_ID }}
deployment_env: ${{ steps.deployment.outputs.env }}
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
desc: |
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
Exercise caution. Use test accounts.

View file

@ -1,19 +1,19 @@
name: Notify element-web
on:
push:
branches: [ develop ]
repository_dispatch:
types: [ upstream-sdk-notify ]
push:
branches: [develop]
repository_dispatch:
types: [upstream-sdk-notify]
jobs:
notify-element-web:
name: "Notify Element Web"
runs-on: ubuntu-latest
# Only respect triggers from our develop branch, ignore that of forks
if: github.repository == 'matrix-org/matrix-react-sdk'
steps:
- name: Notify element-web repo that a new SDK build is on develop
uses: peter-evans/repository-dispatch@v1
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: vector-im/element-web
event-type: element-web-notify
notify-element-web:
name: "Notify Element Web"
runs-on: ubuntu-latest
# Only respect triggers from our develop branch, ignore that of forks
if: github.repository == 'matrix-org/matrix-react-sdk'
steps:
- name: Notify element-web repo that a new SDK build is on develop
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: vector-im/element-web
event-type: element-web-notify

View file

@ -1,12 +1,11 @@
name: Pull Request
on:
pull_request_target:
types: [ opened, edited, labeled, unlabeled, synchronize ]
concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref }}
pull_request_target:
types: [opened, edited, labeled, unlabeled, synchronize]
merge_group:
types: [checks_requested]
jobs:
action:
uses: matrix-org/matrix-js-sdk/.github/workflows/pull_request.yaml@develop
with:
labels: "T-Defect,T-Enhancement,T-Task"
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
action:
uses: matrix-org/matrix-js-sdk/.github/workflows/pull_request.yaml@develop
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}

9
.github/workflows/release-drafter.yml vendored Normal file
View file

@ -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

13
.github/workflows/release-gitflow.yml vendored Normal file
View file

@ -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

View file

@ -1,11 +1,26 @@
name: Release Process
on:
release:
types: [ published ]
concurrency: ${{ github.workflow }}-${{ github.ref }}
workflow_dispatch:
inputs:
mode:
description: What type of release
required: true
default: rc
type: choice
options:
- rc
- final
npm:
description: Publish to npm
required: true
type: boolean
default: true
concurrency: ${{ github.workflow }}
jobs:
npm:
name: Publish
uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
release:
uses: matrix-org/matrix-js-sdk/.github/workflows/release-make.yml@develop
secrets: inherit
with:
final: ${{ inputs.mode == 'final' }}
npm: ${{ inputs.npm }}
downstreams: '["element-hq/element-web"]'

View file

@ -1,15 +1,19 @@
name: SonarQube
on:
workflow_run:
workflows: [ "Tests" ]
types:
- completed
workflow_run:
workflows: ["Tests"]
types:
- completed
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
sonarqube:
name: 🩻 SonarQube
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
sonarqube:
name: 🩻 SonarQube
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
with:
sharded: true

View file

@ -1,94 +1,144 @@
name: Static Analysis
on:
pull_request: { }
push:
branches: [ develop, master ]
repository_dispatch:
types: [ upstream-sdk-notify ]
pull_request: {}
merge_group:
types: [checks_requested]
push:
branches: [develop, master]
repository_dispatch:
types: [upstream-sdk-notify]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
env:
# These must be set for fetchdep.sh to get the right branch
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# fetchdep.sh needs to know our PR number
PR_NUMBER: ${{ github.event.pull_request.number }}
jobs:
ts_lint:
name: "Typescript Syntax Check"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
ts_lint:
name: "Typescript Syntax Check"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
cache: "yarn"
- name: Install Deps
run: "./scripts/ci/install-deps.sh --ignore-scripts"
- name: Typecheck
run: "yarn run lint:types"
- name: Switch js-sdk to release mode
working-directory: node_modules/matrix-js-sdk
run: |
scripts/switch_package_to_release.js
yarn install
yarn run build:compile
yarn run build:types
- name: Typecheck (release mode)
run: "yarn run lint:types"
# Temporary while we directly import matrix-js-sdk/src/* which means we need
# certain @types/* packages to make sense of matrix-js-sdk types.
#- name: Typecheck (release mode; no yarn link)
# if: github.event_name != 'pull_request' && github.ref_name != 'master'
# run: |
# yarn unlink matrix-js-sdk
# yarn add github:matrix-org/matrix-js-sdk#develop
# yarn install --force
# yarn run lint:types
i18n_lint:
name: "i18n Check"
uses: matrix-org/matrix-web-i18n/.github/workflows/i18n_check.yml@main
with:
cache: 'yarn'
hardcoded-words: "Element"
allowed-hardcoded-keys: |
console_dev_note
labs|element_call_video_rooms
labs|feature_disable_call_per_sender_encryption
voip|element_call
- name: Install Deps
run: "./scripts/ci/install-deps.sh --ignore-scripts"
rethemendex_lint:
name: "Rethemendex Check"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Typecheck
run: "yarn run lint:types"
- run: ./res/css/rethemendex.sh
- name: Switch js-sdk to release mode
working-directory: node_modules/matrix-js-sdk
run: |
scripts/switch_package_to_release.js
yarn install
yarn run build:compile
yarn run build:types
- run: git diff --exit-code
- name: Typecheck (release mode)
run: "yarn run lint:types"
js_lint:
name: "ESLint"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
i18n_lint:
name: "i18n Check"
uses: matrix-org/matrix-react-sdk/.github/workflows/i18n_check.yml@develop
- uses: actions/setup-node@v4
with:
cache: "yarn"
js_lint:
name: "ESLint"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
# Does not need branch matching as only analyses this layer
- name: Install Deps
run: "yarn install"
- uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Run Linter
run: "yarn run lint:js"
# Does not need branch matching as only analyses this layer
- name: Install Deps
run: "yarn install"
style_lint:
name: "Style Lint"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Linter
run: "yarn run lint:js"
- uses: actions/setup-node@v4
with:
cache: "yarn"
style_lint:
name: "Style Lint"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
# Does not need branch matching as only analyses this layer
- name: Install Deps
run: "yarn install"
- uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Run Linter
run: "yarn run lint:style"
# Does not need branch matching as only analyses this layer
- name: Install Deps
run: "yarn install"
workflow_lint:
name: "Workflow Lint"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Linter
run: "yarn run lint:style"
- uses: actions/setup-node@v4
with:
cache: "yarn"
analyse_dead_code:
name: "Analyse Dead Code"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
# Does not need branch matching as only analyses this layer
- name: Install Deps
run: "yarn install --frozen-lockfile"
- uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Run Linter
run: "yarn lint:workflows"
- name: Install Deps
run: "scripts/ci/layered.sh"
analyse_dead_code:
name: "Analyse Dead Code"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Dead Code Analysis
run: |
cd element-web
yarn run analyse:unused-exports
- uses: actions/setup-node@v4
with:
cache: "yarn"
- name: Install Deps
run: "scripts/ci/layered.sh"
- name: Dead Code Analysis
run: |
cd element-web
yarn run analyse:unused-exports

View file

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

View file

@ -1,8 +0,0 @@
name: Upgrade Dependencies
on:
workflow_dispatch: { }
jobs:
upgrade:
uses: matrix-org/matrix-js-sdk/.github/workflows/upgrade_dependencies.yml@develop
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}

8
.gitignore vendored
View file

@ -19,11 +19,3 @@ package-lock.json
.vscode
.vscode/
/cypress/videos
/cypress/downloads
/cypress/screenshots
/cypress/synapselogs
# These could have files in them but don't currently
# Cypress will still auto-create them though...
/cypress/performance

View file

@ -1 +1 @@
16
20

View file

@ -1,7 +0,0 @@
version: 2
snapshot:
widths:
- 1024
- 1920
percy:
defer-uploads: true

22
.prettierignore Normal file
View file

@ -0,0 +1,22 @@
/coverage
/lib
/.idea
.vscode
.vscode/
# Legacy skinning file that some people might still have
/src/component-index.js
/.npmrc
/*.log
package-lock.json
yarn.lock
/src/i18n/strings
# 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

1
.prettierrc.js Normal file
View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
Contributing code to matrix-react-sdk
=====================================
# Contributing code to matrix-react-sdk
matrix-react-sdk follows the same pattern as https://github.com/vector-im/element-web/blob/develop/CONTRIBUTING.md

126
README.md
View file

@ -1,26 +1,25 @@
[![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)
[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=matrix-react-sdk&metric=bugs)](https://sonarcloud.io/summary/new_code?id=matrix-react-sdk)
matrix-react-sdk
================
# matrix-react-sdk
This is a react-based SDK for inserting a Matrix chat/voip client into a web page.
This package provides the React components needed to build a Matrix web client
using React. It is not useable in isolation, and instead must be used from
using React. It is not useable in isolation, and instead must be used from
a 'skin'. A skin provides:
* Customised implementations of presentation components.
* Custom CSS
* The containing application
* Zero or more 'modules' containing non-UI functionality
- Customised implementations of presentation components.
- Custom CSS
- The containing application
- Zero or more 'modules' containing non-UI functionality
As of Aug 2018, the only skin that exists is
[`vector-im/element-web`](https://github.com/vector-im/element-web/); it and
@ -28,19 +27,15 @@ 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
---------------
## Developer Guide
Platform Targets:
* Chrome, Firefox and Safari.
* WebRTC features (VoIP and Video calling) are only available in Chrome & Firefox.
* Mobile Web is not currently a target platform - instead please use the native
iOS (https://github.com/matrix-org/matrix-ios-kit) and Android
(https://github.com/matrix-org/matrix-android-sdk2) SDKs.
- Chrome, Firefox and Safari.
- WebRTC features (VoIP and Video calling) are only available in Chrome & Firefox.
- Mobile Web is not currently a target platform - instead please use the native
iOS (https://github.com/matrix-org/matrix-ios-kit) and Android
(https://github.com/matrix-org/matrix-android-sdk2) SDKs.
All code lands on the `develop` branch - `master` is only used for stable releases.
**Please file PRs against `develop`!!**
@ -52,22 +47,23 @@ Our code style is also the same as Element's:
https://github.com/vector-im/element-web/blob/develop/code_style.md
Code should be committed as follows:
* All new components:
https://github.com/matrix-org/matrix-react-sdk/tree/master/src/components
* Element-specific components:
https://github.com/vector-im/element-web/tree/master/src/components
* In practice, `matrix-react-sdk` is still evolving so fast that the
maintenance burden of customising and overriding these components for
Element can seriously impede development. So right now, there should be
very few (if any) customisations for Element.
* CSS: https://github.com/matrix-org/matrix-react-sdk/tree/master/res/css
* Theme specific CSS & resources:
https://github.com/matrix-org/matrix-react-sdk/tree/master/res/themes
- All new components:
https://github.com/matrix-org/matrix-react-sdk/tree/master/src/components
- Element-specific components:
https://github.com/vector-im/element-web/tree/master/src/components
- In practice, `matrix-react-sdk` is still evolving so fast that the
maintenance burden of customising and overriding these components for
Element can seriously impede development. So right now, there should be
very few (if any) customisations for Element.
- CSS: https://github.com/matrix-org/matrix-react-sdk/tree/master/res/css
- Theme specific CSS & resources:
https://github.com/matrix-org/matrix-react-sdk/tree/master/res/themes
React components in matrix-react-sdk come in two different flavours:
'structures' and 'views'. Structures are stateful components which handle the
'structures' and 'views'. Structures are stateful components which handle the
more complicated business logic of the app, delegating their actual presentation
rendering to stateless 'view' components. For instance, the RoomView component
rendering to stateless 'view' components. For instance, the RoomView component
that orchestrates the act of visualising the contents of a given Matrix chat
room tracks lots of state for its child components which it passes into them for
visual rendering via props.
@ -75,74 +71,72 @@ visual rendering via props.
Good separation between the components is maintained by adopting various best
practices that anyone working with the SDK needs to be aware of and uphold:
* Components are named with upper camel case (e.g. views/rooms/EventTile.js)
- Components are named with upper camel case (e.g. views/rooms/EventTile.js)
* They are organised in a typically two-level hierarchy - first whether the
- They are organised in a typically two-level hierarchy - first whether the
component is a view or a structure, and then a broad functional grouping
(e.g. 'rooms' here)
* The view's CSS file MUST have the same name (e.g. view/rooms/MessageTile.css).
- The view's CSS file MUST have the same name (e.g. view/rooms/MessageTile.css).
CSS for matrix-react-sdk currently resides in
https://github.com/matrix-org/matrix-react-sdk/tree/master/res/css.
* Per-view CSS is optional - it could choose to inherit all its styling from
- Per-view CSS is optional - it could choose to inherit all its styling from
the context of the rest of the app, although this is unusual for any but
* Theme specific CSS & resources:
https://github.com/matrix-org/matrix-react-sdk/tree/master/res/themes
structural components (lacking presentation logic) and the simplest view
components.
- Theme specific CSS & resources:
https://github.com/matrix-org/matrix-react-sdk/tree/master/res/themes
structural components (lacking presentation logic) and the simplest view
components.
* The view MUST *only* refer to the CSS rules defined in its own CSS file.
- The view MUST _only_ refer to the CSS rules defined in its own CSS file.
'Stealing' styling information from other components (including parents)
is not cool, as it breaks the independence of the components.
* CSS classes are named with an app-specific name-spacing prefix to try to
avoid CSS collisions. The base skin shipped by Matrix.org with the
matrix-react-sdk uses the naming prefix "mx_". A company called Yoyodyne
Inc might use a prefix like "yy_" for its app-specific classes.
- CSS classes are named with an app-specific name-spacing prefix to try to
avoid CSS collisions. The base skin shipped by Matrix.org with the
matrix-react-sdk uses the naming prefix "mx*". A company called Yoyodyne
Inc might use a prefix like "yy*" for its app-specific classes.
* CSS classes use upper camel case when they describe React components - e.g.
- CSS classes use upper camel case when they describe React components - e.g.
.mx_MessageTile is the selector for the CSS applied to a MessageTile view.
* CSS classes for DOM elements within a view which aren't components are named
- CSS classes for DOM elements within a view which aren't components are named
by appending a lower camel case identifier to the view's class name - e.g.
.mx_MessageTile_randomDiv is how you'd name the class of an arbitrary div
within the MessageTile view.
* We deliberately use vanilla CSS 3.0 to avoid adding any more magic
dependencies into the mix than we already have. App developers are welcome
to use whatever floats their boat however. In future we'll start using
- We deliberately use vanilla CSS 3.0 to avoid adding any more magic
dependencies into the mix than we already have. App developers are welcome
to use whatever floats their boat however. In future we'll start using
css-next to pull in features like CSS variable support.
* The CSS for a component can override the rules for child components.
For instance, .mx_RoomList .mx_RoomTile {} would be the selector to override
- The CSS for a component can override the rules for child components.
For instance, .mx*RoomList .mx_RoomTile {} would be the selector to override
styles of RoomTiles when viewed in the context of a RoomList view.
Overrides *must* be scoped to the View's CSS class - i.e. don't just define
Overrides \_must* be scoped to the View's CSS class - i.e. don't just define
.mx_RoomTile {} in RoomList.css - only RoomTile.css is allowed to define its
own CSS. Instead, say .mx_RoomList .mx_RoomTile {} to scope the override
only to the context of RoomList views. N.B. overrides should be relatively
own CSS. Instead, say .mx_RoomList .mx_RoomTile {} to scope the override
only to the context of RoomList views. N.B. overrides should be relatively
rare as in general CSS inheritance should be enough.
* Components should render only within the bounding box of their outermost DOM
- Components should render only within the bounding box of their outermost DOM
element. Page-absolute positioning and negative CSS margins and similar are
generally not cool and stop the component from being reused easily in
different places.
Originally `matrix-react-sdk` followed the Atomic design pattern as per
http://patternlab.io to try to encourage a modular architecture. However, we
http://patternlab.io to try to encourage a modular architecture. However, we
found that the grouping of components into atoms/molecules/organisms
made them harder to find relative to a functional split, and didn't emphasise
the distinction between 'structural' and 'view' components, so we backed away
from it.
Github Issues
-------------
## Github Issues
All issues should be filed under https://github.com/vector-im/element-web/issues
for now.
Development
-----------
## Development
Ensure you have the latest LTS version of Node.js installed.
@ -210,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.

View file

@ -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"
}

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
const EventEmitter = require("events");
const { LngLat, NavigationControl, LngLatBounds, AttributionControl } = require('maplibre-gl');
const { LngLat, NavigationControl, LngLatBounds } = require("maplibre-gl");
class MockMap extends EventEmitter {
addControl = jest.fn();
@ -28,11 +28,12 @@ class MockMap extends EventEmitter {
}
const MockMapInstance = new MockMap();
class MockAttributionControl {}
class MockGeolocateControl extends EventEmitter {
trigger = jest.fn();
}
const MockGeolocateInstance = new MockGeolocateControl();
const MockMarker = {}
const MockMarker = {};
MockMarker.setLngLat = jest.fn().mockReturnValue(MockMarker);
MockMarker.addTo = jest.fn().mockReturnValue(MockMarker);
MockMarker.remove = jest.fn().mockReturnValue(MockMarker);
@ -43,5 +44,5 @@ module.exports = {
LngLat,
LngLatBounds,
NavigationControl,
AttributionControl,
AttributionControl: MockAttributionControl,
};

View file

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

View file

@ -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;
}

View file

@ -1 +0,0 @@
module.exports = jest.fn();

View file

@ -1,18 +1,21 @@
module.exports = {
"sourceMaps": "inline",
"presets": [
["@babel/preset-env", {
"targets": [
"last 2 Chrome versions",
"last 2 Firefox versions",
"last 2 Safari versions",
"last 2 Edge versions",
],
}],
sourceMaps: "inline",
presets: [
[
"@babel/preset-env",
{
targets: [
"last 2 Chrome versions",
"last 2 Firefox versions",
"last 2 Safari versions",
"last 2 Edge versions",
],
},
],
"@babel/preset-typescript",
"@babel/preset-react",
],
"plugins": [
plugins: [
"@babel/plugin-proposal-export-default-from",
"@babel/plugin-proposal-numeric-separator",
"@babel/plugin-proposal-class-properties",

View file

@ -1,37 +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',
experimentalSessionAndOrigin: true,
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
},
retries: {
runMode: 4,
openMode: 0,
},
});

View file

@ -1,155 +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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import { SettingLevel } from "../../../src/settings/SettingLevel";
describe("Composer", () => {
let synapse: SynapseInstance;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
describe("CIDER", () => {
beforeEach(() => {
cy.initTestUser(synapse, "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.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[aria-label="Send message"]').click();
// It has been sent
cy.contains('.mx_EventTile_body', 'my message 0');
// Type another and press Enter afterwards
cy.get('div[contenteditable=true]').type('my message 1{enter}');
// It was sent
cy.contains('.mx_EventTile_body', 'my message 1');
});
it("can write formatted text", () => {
cy.get('div[contenteditable=true]').type('my bold{ctrl+b} message');
cy.get('div[aria-label="Send message"]').click();
// Note: both "bold" and "message" are bold, which is probably surprising
cy.contains('.mx_EventTile_body strong', 'bold message');
});
it("should allow user to input emoji via graphical picker", () => {
cy.getComposer(false).within(() => {
cy.get('[aria-label="Emoji"]').click();
});
cy.get('[data-testid="mx_EmojiPicker"]').within(() => {
cy.contains(".mx_EmojiPicker_item", "😇").click();
});
cy.get(".mx_ContextualMenu_background").click(); // Close emoji picker
cy.get('div[contenteditable=true]').type("{enter}"); // Send message
cy.contains(".mx_EventTile_body", "😇");
});
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{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.contains('.mx_EventTile_body', 'my message 3');
});
});
});
describe("WYSIWYG", () => {
beforeEach(() => {
cy.enableLabsFeature("feature_wysiwyg_composer");
cy.initTestUser(synapse, "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.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[aria-label="Send message"]').click();
// It has been sent
cy.contains('.mx_EventTile_body', 'my message 0');
// Type another
cy.get('div[contenteditable=true]').type('my message 1');
// Press enter. Would be nice to just use {enter} but we can't because Cypress
// does not trigger an insertParagraph when you do that.
cy.get('div[contenteditable=true]').trigger('input', { inputType: "insertParagraph" });
// It was sent
cy.contains('.mx_EventTile_body', 'my message 1');
});
it("can write formatted text", () => {
cy.get('div[contenteditable=true]').type('my {ctrl+b}bold{ctrl+b} message');
cy.get('div[aria-label="Send message"]').click();
cy.contains('.mx_EventTile_body strong', 'bold');
});
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]').trigger('input', { inputType: "insertParagraph" });
// 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.contains('.mx_EventTile_body', 'my message 3');
});
});
});
});

View file

@ -1,64 +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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import Chainable = Cypress.Chainable;
function openCreateRoomDialog(): Chainable<JQuery<HTMLElement>> {
cy.get('[aria-label="Add room"]').click();
cy.get('.mx_ContextualMenu [aria-label="New room"]').click();
return cy.get(".mx_CreateRoomDialog");
}
describe("Create Room", () => {
let synapse: SynapseInstance;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Jim");
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
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.get('[label="Name"]').type(name);
cy.get('[label="Topic (optional)"]').type(topic);
// Change room to public
cy.get('[aria-label="Room visibility"]').click();
cy.get("#mx_JoinRuleDropdown__public").click();
// Fill room address
cy.get('[label="Room address"]').type("test-room-1");
// Submit
cy.get(".mx_Dialog_primary").click();
});
cy.url().should("contain", "/#/room/#test-room-1:localhost");
cy.contains(".mx_RoomHeader_nametext", name);
cy.contains(".mx_RoomHeader_topic", topic);
});
});

View file

@ -1,177 +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, Room } from "matrix-js-sdk/src/matrix";
import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS";
import { SynapseInstance } from "../../plugins/synapsedocker";
import Chainable = Cypress.Chainable;
type EmojiMapping = [emoji: string, name: string];
interface CryptoTestContext extends Mocha.Context {
synapse: SynapseInstance;
bob: MatrixClient;
}
const waitForVerificationRequest = (cli: MatrixClient): Promise<VerificationRequest> => {
return new Promise<VerificationRequest>(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 openRoomInfo = () => {
cy.get(".mx_RightPanel_roomSummaryButton").click();
return cy.get(".mx_RightPanel");
};
const checkDMRoom = () => {
cy.contains(".mx_TextualEvent", "Alice invited Bob").should("exist");
cy.contains(".mx_RoomView_body .mx_cryptoEvent", "Encryption enabled").should("exist");
};
const startDMWithBob = function(this: CryptoTestContext) {
cy.get('.mx_RoomList [aria-label="Start chat"]').click();
cy.get('[data-testid="invite-dialog-input"]').type(this.bob.getUserId());
cy.contains(".mx_InviteDialog_tile_nameStack_name", "Bob").click();
cy.contains(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name", "Bob").should("exist");
cy.get(".mx_InviteDialog_goButton").click();
};
const testMessages = function(this: CryptoTestContext) {
// check the invite message
cy.contains(".mx_EventTile_body", "Hey!").closest(".mx_EventTile").within(() => {
cy.get(".mx_EventTile_e2eIcon_warning").should("not.exist");
});
// Bob sends a response
cy.get<Room>("@bobsRoom").then((room) => {
this.bob.sendTextMessage(room.roomId, "Hoo!");
});
cy.contains(".mx_EventTile_body", "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<void>(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.contains(".mx_TextualEvent", "Bob joined the room").should("exist");
};
const handleVerificationRequest = (request: VerificationRequest): Chainable<EmojiMapping[]> => {
return cy.wrap(new Promise<EmojiMapping[]>((resolve) => {
const onShowSas = (event: ISasEvent) => {
verifier.off("show_sas", onShowSas);
event.confirm();
verifier.done();
resolve(event.sas.emoji);
};
const verifier = request.beginKeyVerification("m.sas.v1");
verifier.on("show_sas", onShowSas);
verifier.verify();
}));
};
const verify = function(this: CryptoTestContext) {
const bobsVerificationRequestPromise = waitForVerificationRequest(this.bob);
openRoomInfo().within(() => {
cy.get(".mx_RoomSummaryCard_icon_people").click();
cy.contains(".mx_EntityTile_name", "Bob").click();
cy.contains(".mx_UserInfo_verifyButton", "Verify").click();
cy.contains(".mx_AccessibleButton", "Start Verification").click();
cy.wrap(bobsVerificationRequestPromise).then((verificationRequest: VerificationRequest) => {
verificationRequest.accept();
return verificationRequest;
}).as("bobsVerificationRequest");
cy.contains(".mx_AccessibleButton", "Verify by emoji").click();
cy.get<VerificationRequest>("@bobsVerificationRequest").then((request: VerificationRequest) => {
return handleVerificationRequest(request).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.contains(".mx_AccessibleButton", "They match").click();
cy.contains("You've successfully verified Bob!").should("exist");
cy.contains(".mx_AccessibleButton", "Got it").click();
});
};
describe("Cryptography", function() {
beforeEach(function() {
cy.startSynapse("default").as("synapse").then((synapse: SynapseInstance) => {
cy.initTestUser(synapse, "Alice");
cy.getBot(synapse, { displayName: "Bob", autoAcceptInvites: false }).as("bob");
});
});
afterEach(function(this: CryptoTestContext) {
cy.stopSynapse(this.synapse);
});
it("setting up secure key backup should work", () => {
cy.openUserSettings("Security & Privacy");
cy.contains(".mx_AccessibleButton", "Set up Secure Backup").click();
cy.get(".mx_Dialog").within(() => {
cy.contains(".mx_Dialog_primary", "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.contains(".mx_AccessibleButton", "Download").click();
cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click();
cy.contains(".mx_Dialog_title", "Setting up keys").should("exist");
cy.contains(".mx_Dialog_title", "Setting up keys").should("not.exist");
});
return;
});
it("creating a DM should work, being e2e-encrypted / user verification", function(this: CryptoTestContext) {
cy.bootstrapCrossSigning();
startDMWithBob.call(this);
// send first message
cy.get(".mx_BasicMessageComposer_input")
.click()
.should("have.focus")
.type("Hey!{enter}");
checkDMRoom();
bobJoin.call(this);
testMessages.call(this);
verify.call(this);
});
});

View file

@ -1,70 +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.
*/
/// <reference types="cypress" />
import { MessageEvent } from "matrix-events-sdk";
import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests";
import type { EventType } from "matrix-js-sdk/src/@types/event";
import { SynapseInstance } from "../../plugins/synapsedocker";
import Chainable = Cypress.Chainable;
const sendEvent = (roomId: string): Chainable<ISendEventResponse> => {
return cy.sendEvent(
roomId,
null,
"m.room.message" as EventType,
MessageEvent.from("Message").serialize().content,
);
};
describe("Editing", () => {
let synapse: SynapseInstance;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Edith").then(() => {
cy.injectAxe();
return cy.createRoom({ name: "Test room" }).as("roomId");
});
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
it("should close the composer when clicking save after making a change and undoing it", () => {
cy.get<string>("@roomId").then(roomId => {
sendEvent(roomId);
cy.visit("/#/room/" + roomId);
});
// Edit message
cy.contains(".mx_RoomView_body .mx_EventTile .mx_EventTile_line", "Message").within(() => {
cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover
cy.checkA11y();
cy.get(".mx_BasicMessageComposer_input").type("Foo{backspace}{backspace}{backspace}{enter}");
cy.checkA11y();
});
cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Message");
// Assert that the edit composer has gone away
cy.get(".mx_EditMessageComposer").should("not.exist");
});
});

View file

@ -1,251 +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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
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 = `
<html lang="en">
<head>
<title>Fake Integration Manager</title>
</head>
<body>
<input type="text" id="target-room-id"/>
<input type="text" id="target-user-id"/>
<button name="Send" id="send-action">Press to send action</button>
<button name="Close" id="close">Press to close</button>
<script>
document.getElementById("send-action").onclick = () => {
window.parent.postMessage(
{
action: "kick",
room_id: document.getElementById("target-room-id").value,
user_id: document.getElementById("target-user-id").value,
reason: "${KICK_REASON}",
},
'*',
);
};
document.getElementById("close").onclick = () => {
window.parent.postMessage(
{
action: "close_scalar",
},
'*',
);
};
</script>
</body>
</html>
`;
function openIntegrationManager() {
cy.get(".mx_RightPanel_roomSummaryButton").click();
cy.get(".mx_RoomSummaryCard_appsGroup").within(() => {
cy.contains("Add widgets, bridges & bots").click();
});
}
function closeIntegrationManager(integrationManagerUrl: string) {
cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => {
cy.get("#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.get("#send-action").should("exist").click();
});
}
function expectKickedMessage(shouldExist: boolean) {
// Expand any event summaries
cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=false]").click({ multiple: true });
// Check for the event message (or lack thereof)
cy.contains(".mx_EventTile_line", `${USER_DISPLAY_NAME} removed ${BOT_DISPLAY_NAME}: ${KICK_REASON}`)
.should(shouldExist ? "exist" : "not.exist");
}
describe("Integration Manager: Kick", () => {
let testUser: UserCredentials;
let synapse: SynapseInstance;
let integrationManagerUrl: string;
beforeEach(() => {
cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then(url => {
integrationManagerUrl = url;
});
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, 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(synapse, { displayName: BOT_DISPLAY_NAME, autoAcceptInvites: true }).as("bob");
});
});
afterEach(() => {
cy.stopSynapse(synapse);
cy.stopWebServers();
});
it("should kick the target", () => {
cy.all([
cy.get<MatrixClient>("@bob"),
cy.get<string>("@roomId"),
cy.get<{}>("@integrationManager"),
]).then(([targetUser, roomId]) => {
const targetUserId = targetUser.getUserId();
cy.viewRoomByName(ROOM_NAME);
cy.inviteUser(roomId, targetUserId);
cy.contains(`${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<MatrixClient>("@bob"),
cy.get<string>("@roomId"),
cy.get<{}>("@integrationManager"),
]).then(([targetUser, roomId]) => {
const targetUserId = targetUser.getUserId();
cy.viewRoomByName(ROOM_NAME);
cy.inviteUser(roomId, targetUserId);
cy.contains(`${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<MatrixClient>("@bob"),
cy.get<string>("@roomId"),
cy.get<{}>("@integrationManager"),
]).then(([targetUser, roomId]) => {
const targetUserId = targetUser.getUserId();
cy.viewRoomByName(ROOM_NAME);
cy.inviteUser(roomId, targetUserId);
cy.contains(`${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<MatrixClient>("@bob"),
cy.get<string>("@roomId"),
cy.get<{}>("@integrationManager"),
]).then(([targetUser, roomId]) => {
const targetUserId = targetUser.getUserId();
cy.viewRoomByName(ROOM_NAME);
cy.inviteUser(roomId, targetUserId);
cy.contains(`${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<MatrixClient>("@bob"),
cy.get<string>("@roomId"),
cy.get<{}>("@integrationManager"),
]).then(([targetUser, roomId]) => {
const targetUserId = targetUser.getUserId();
cy.viewRoomByName(ROOM_NAME);
openIntegrationManager();
sendActionFromIntegrationManager(integrationManagerUrl, roomId, targetUserId);
closeIntegrationManager(integrationManagerUrl);
expectKickedMessage(false);
});
});
});

View file

@ -1,174 +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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import { MatrixClient } from "../../global";
import Chainable = Cypress.Chainable;
interface Charly {
client: MatrixClient;
displayName: string;
}
describe("Lazy Loading", () => {
let synapse: SynapseInstance;
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.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Alice");
cy.getBot(synapse, {
displayName: "Bob",
startClient: false,
autoAcceptInvites: false,
}).then(_bob => {
bob = _bob;
});
for (let i = 1; i <= 10; i++) {
const displayName = `Charly #${i}`;
cy.getBot(synapse, {
displayName,
startClient: false,
autoAcceptInvites: false,
}).then(client => {
charlies[i - 1] = { displayName, client };
});
}
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
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<string>("@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_HeaderButtons [aria-label="Room info"]').click();
cy.get(".mx_RoomSummaryCard").within(() => {
cy.get(".mx_RoomSummaryCard_icon_people").click();
});
}
function getMemberInMemberlist(name: string): Chainable<JQuery> {
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<string>("@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<string>("@roomId").then(async roomId => {
for (const charly of charlies) {
await charly.client.leave(roomId);
}
});
checkMemberListLacksCharlies(charlies);
});
});

View file

@ -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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import Chainable = Cypress.Chainable;
describe("Location sharing", () => {
let synapse: SynapseInstance;
const selectLocationShareTypeOption = (shareType: string): Chainable<JQuery> => {
return cy.get(`[data-test-id="share-location-option-${shareType}"]`);
};
const submitShareLocation = (): void => {
cy.get('[data-test-id="location-picker-submit-button"]').click();
};
beforeEach(() => {
cy.window().then(win => {
win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests
});
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Tom");
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
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.get('[aria-label="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.get('[aria-label="Close dialog"]').click();
cy.get('.mx_Marker')
.should('exist');
});
});

View file

@ -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.
*/
/// <reference types="cypress" />
import { SinonStub } from "cypress/types/sinon";
import { SynapseInstance } from "../../plugins/synapsedocker";
describe("Consent", () => {
let synapse: SynapseInstance;
beforeEach(() => {
cy.startSynapse("consent").then(data => {
synapse = data;
cy.initTestUser(synapse, "Bob");
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
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.contains("#mx_BaseDialog_title", "Terms and Conditions");
cy.get(".mx_Dialog_primary").click();
});
cy.get<SinonStub>("@windowOpen").then(stub => {
const url = stub.getCall(0).args[0];
// Go to Synapse's consent page and accept it
cy.origin(synapse.baseUrl, { args: { url } }, ({ url }) => {
cy.visit(url);
cy.get('[type="submit"]').click();
cy.contains("p", "Danke schon");
});
});
// 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({});
});
});

View file

@ -1,104 +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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
describe("Login", () => {
let synapse: SynapseInstance;
afterEach(() => {
cy.stopSynapse(synapse);
});
describe("m.login.password", () => {
const username = "user1234";
const password = "p4s5W0rD";
beforeEach(() => {
cy.startSynapse("consent").then(data => {
synapse = data;
cy.registerUser(synapse, username, password);
cy.visit("/#/login");
});
});
it("logs in with an existing account and lands on the home screen", () => {
cy.injectAxe();
cy.get("#mx_LoginForm_username", { timeout: 15000 }).should("be.visible");
cy.percySnapshot("Login");
cy.checkA11y();
cy.get(".mx_ServerPicker_change").click();
cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl);
cy.get(".mx_ServerPickerDialog_continue").click();
// wait for the dialog to go away
cy.get('.mx_ServerPickerDialog').should('not.exist');
cy.get("#mx_LoginForm_username").type(username);
cy.get("#mx_LoginForm_password").type(password);
cy.get(".mx_Login_submit").click();
cy.url().should('contain', '/#/home', { timeout: 30000 });
});
});
describe("logout", () => {
beforeEach(() => {
cy.startSynapse("consent").then(data => {
synapse = data;
cy.initTestUser(synapse, "Erin");
});
});
it("should go to login page on logout", () => {
cy.get('[aria-label="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.get(".mx_UserMenu_iconSignOut").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.get('[aria-label="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.get(".mx_UserMenu_iconSignOut").click();
});
cy.url().should("contains", "decoder-ring");
});
});
});

View file

@ -1,341 +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.
*/
/// <reference types="cypress" />
import { PollResponseEvent } from "matrix-events-sdk";
import { SynapseInstance } from "../../plugins/synapsedocker";
import { MatrixClient } from "../../global";
import Chainable = Cypress.Chainable;
const hideTimestampCSS = ".mx_MessageTimestamp { visibility: hidden !important; }";
describe("Polls", () => {
let synapse: SynapseInstance;
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.get('#poll-topic-input').type(title);
options.forEach((option, index) => {
const optionId = `#pollcreate_option_${index}`;
// click 'add option' button if needed
if (pollCreateDialog.find(optionId).length === 0) {
cy.get('.mx_PollCreateDialog_addOption').scrollIntoView().click();
}
cy.get(optionId).scrollIntoView().type(option);
});
});
cy.get('.mx_Dialog button[type="submit"]').click();
};
const getPollTile = (pollId: string): Chainable<JQuery> => {
return cy.get(`.mx_EventTile[data-scroll-tokens="${pollId}"]`);
};
const getPollOption = (pollId: string, optionText: string): Chainable<JQuery> => {
return getPollTile(pollId).contains('.mx_MPollBody_option .mx_StyledRadioButton', optionText);
};
const expectPollOptionVoteCount = (pollId: string, optionText: string, votes: number): void => {
getPollOption(pollId, optionText).within(() => {
cy.get('.mx_MPollBody_optionVoteCount').should('contain', `${votes} vote`);
});
};
const botVoteForOption = (bot: MatrixClient, roomId: string, pollId: string, optionText: string): void => {
getPollOption(pollId, optionText).within(ref => {
cy.get('input[type="radio"]').invoke('attr', 'value').then(optionId => {
const pollVote = PollResponseEvent.from([optionId], pollId).serialize();
bot.sendEvent(
roomId,
pollVote.type,
pollVote.content,
);
});
});
};
beforeEach(() => {
cy.enableLabsFeature("feature_thread");
cy.window().then(win => {
win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests
});
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Tom");
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
it("should be creatable and votable", () => {
let bot: MatrixClient;
cy.getBot(synapse, { 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.contains(".mx_TextualEvent", "BotBob joined the room").should("exist");
});
cy.openMessageComposerOptions().within(() => {
cy.get('[aria-label="Poll"]').click();
});
cy.get('.mx_CompoundDialog').percySnapshotElement('Polls Composer');
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<string>("@pollId").then(pollId => {
getPollTile(pollId).percySnapshotElement('Polls Timeline tile - no votes', { percyCSS: hideTimestampCSS });
// 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').should('contain', '1 vote cast');
// 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(synapse, { 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.get('[aria-label="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<string>("@pollId").then(pollId => {
// Open context menu
getPollTile(pollId).rightclick();
// Select edit item
cy.get('.mx_ContextualMenu').within(() => {
cy.get('[aria-label="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(synapse, { 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.get('[aria-label="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<string>("@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.get('.mx_ContextualMenu').within(() => {
cy.get('[aria-label="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(synapse, { displayName: "BotBob" }).then(_bot => {
botBob = _bot;
});
cy.getBot(synapse, { 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.contains(".mx_TextualEvent", "and one other were invited and joined").should("exist");
});
cy.openMessageComposerOptions().within(() => {
cy.get('[aria-label="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<string>("@pollId").then(pollId => {
// Bob starts thread on the poll
botBob.sendMessage(roomId, pollId, {
body: "Hello there",
msgtype: "m.text",
});
// open the thread summary
cy.get(".mx_RoomView_body .mx_ThreadSummary").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').should('contain', '2 votes cast');
// and thread view
cy.get('.mx_ThreadView .mx_MPollBody_totalVotes').should('contain', '2 votes cast');
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();
});
});
});
});

View file

@ -1,128 +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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
describe("Registration", () => {
let synapse: SynapseInstance;
beforeEach(() => {
cy.visit("/#/register");
cy.startSynapse("consent").then(data => {
synapse = data;
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
it("registers an account and lands on the home screen", () => {
cy.injectAxe();
cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click();
cy.get(".mx_ServerPickerDialog_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.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl);
cy.get(".mx_ServerPickerDialog_continue").click();
// wait for the dialog to go away
cy.get('.mx_ServerPickerDialog').should('not.exist');
cy.get("#mx_RegistrationForm_username").should("be.visible");
// Hide the server text as it contains the randomly allocated Synapse port
const percyCSS = ".mx_ServerPicker_server { visibility: hidden !important; }";
cy.percySnapshot("Registration", { percyCSS });
cy.checkA11y();
cy.get("#mx_RegistrationForm_username").type("alice");
cy.get("#mx_RegistrationForm_password").type("totally a great password");
cy.get("#mx_RegistrationForm_passwordConfirm").type("totally a great password");
cy.get(".mx_Login_submit").click();
cy.get(".mx_RegistrationEmailPromptDialog").should("be.visible");
cy.percySnapshot("Registration email prompt", { percyCSS });
cy.checkA11y();
cy.get(".mx_RegistrationEmailPromptDialog button.mx_Dialog_primary").click();
cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy").should("be.visible");
cy.percySnapshot("Registration terms prompt", { percyCSS });
cy.checkA11y();
cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy input").click();
cy.get(".mx_InteractiveAuthEntryComponents_termsSubmit").click();
cy.get(".mx_UseCaseSelection_skip", { timeout: 30000 }).should("exist");
cy.percySnapshot("Use-case selection screen");
cy.checkA11y();
cy.get(".mx_UseCaseSelection_skip .mx_AccessibleButton").click();
cy.url().should('contain', '/#/home');
cy.get('[aria-label="User menu"]').click();
cy.get('[aria-label="Security & Privacy"]').click();
cy.get(".mx_DevicesPanel_myDevice .mx_DevicesPanel_deviceTrust .mx_E2EIcon")
.should("have.class", "mx_E2EIcon_verified");
});
it("should require username to fulfil requirements and be available", () => {
cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click();
cy.get(".mx_ServerPickerDialog_continue").should("be.visible");
cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl);
cy.get(".mx_ServerPickerDialog_continue").click();
// wait for the dialog to go away
cy.get('.mx_ServerPickerDialog').should('not.exist');
cy.get("#mx_RegistrationForm_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.get("#mx_RegistrationForm_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.get("#mx_RegistrationForm_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.get("#mx_RegistrationForm_username").type("{selectAll}{backspace}foobar");
cy.get(".mx_Field_tooltip").should("not.have.class", "mx_Tooltip_visible");
});
});

View file

@ -1,76 +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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
describe("Pills", () => {
let synapse: SynapseInstance;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Sally");
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
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<string>("@targetRoomId"),
cy.get<string>("@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.get(".mx_SendMessageComposer .mx_BasicMessageComposer_input")
.type(`Hello world! Join here: #${targetLocalpart.substring(0, 3)}`);
cy.get(".mx_Autocomplete_Completion_title").click();
cy.get(".mx_MessageComposer_sendMessage").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_linkText")
.should("have.css", "pointer-events", "none")
.click({ force: true }); // force is to ensure we bypass pointer-events
cy.url().should("contain", localUrl);
});
});
});

View file

@ -1,139 +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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import Chainable = Cypress.Chainable;
const ROOM_NAME = "Test room";
const SPACE_NAME = "Test space";
const NAME = "Alice";
const getMemberTileByName = (name: string): Chainable<JQuery<HTMLElement>> => {
return cy.get(`.mx_EntityTile, [title="${name}"]`);
};
const goBack = (): Chainable<JQuery<HTMLElement>> => {
return cy.get(".mx_BaseCard_back").click();
};
const viewRoomSummaryByName = (name: string): Chainable<JQuery<HTMLElement>> => {
cy.viewRoomByName(name);
cy.get(".mx_RightPanel_roomSummaryButton").click();
return checkRoomSummaryCard(name);
};
const checkRoomSummaryCard = (name: string): Chainable<JQuery<HTMLElement>> => {
cy.get(".mx_RoomSummaryCard").should("have.length", 1);
return cy.get(".mx_BaseCard_header").should("contain", name);
};
describe("RightPanel", () => {
let synapse: SynapseInstance;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, NAME).then(() =>
cy.window({ log: false }).then(() => {
cy.createRoom({ name: ROOM_NAME });
cy.createSpace({ name: SPACE_NAME });
}),
);
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
describe("in rooms", () => {
it("should handle clicking add widgets", () => {
viewRoomSummaryByName(ROOM_NAME);
cy.get(".mx_RoomSummaryCard_appsGroup .mx_AccessibleButton").click();
cy.get(".mx_IntegrationManager").should("have.length", 1);
});
it("should handle viewing export chat", () => {
viewRoomSummaryByName(ROOM_NAME);
cy.get(".mx_RoomSummaryCard_icon_export").click();
cy.get(".mx_ExportDialog").should("have.length", 1);
});
it("should handle viewing share room", () => {
viewRoomSummaryByName(ROOM_NAME);
cy.get(".mx_RoomSummaryCard_icon_share").click();
cy.get(".mx_ShareDialog").should("have.length", 1);
});
it("should handle viewing room settings", () => {
viewRoomSummaryByName(ROOM_NAME);
cy.get(".mx_RoomSummaryCard_icon_settings").click();
cy.get(".mx_RoomSettingsDialog").should("have.length", 1);
cy.get(".mx_Dialog_title").should("contain", ROOM_NAME);
});
it("should handle viewing files", () => {
viewRoomSummaryByName(ROOM_NAME);
cy.get(".mx_RoomSummaryCard_icon_files").click();
cy.get(".mx_FilePanel").should("have.length", 1);
cy.get(".mx_FilePanel_empty").should("have.length", 1);
goBack();
checkRoomSummaryCard(ROOM_NAME);
});
it("should handle viewing room member", () => {
viewRoomSummaryByName(ROOM_NAME);
cy.get(".mx_RoomSummaryCard_icon_people").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").should("contain", NAME);
goBack();
cy.get(".mx_MemberList").should("have.length", 1);
goBack();
checkRoomSummaryCard(ROOM_NAME);
});
});
describe("in spaces", () => {
it("should handle viewing space member", () => {
cy.viewSpaceHomeByName(SPACE_NAME);
cy.get(".mx_RoomInfoLine_members").click();
cy.get(".mx_MemberList").should("have.length", 1);
cy.get(".mx_RightPanel_scopeHeader").should("contain", SPACE_NAME);
getMemberTileByName(NAME).click();
cy.get(".mx_UserInfo").should("have.length", 1);
cy.get(".mx_UserInfo_profile").should("contain", NAME);
cy.get(".mx_RightPanel_scopeHeader").should("contain", SPACE_NAME);
goBack();
cy.get(".mx_MemberList").should("have.length", 1);
});
});
});

View file

@ -1,103 +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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import { MatrixClient } from "../../global";
describe("Room Directory", () => {
let synapse: SynapseInstance;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Ray");
cy.getBot(synapse, { displayName: "Paul" }).as("bot");
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
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.get(".mx_Field input").type("gaming");
cy.contains(".mx_AccessibleButton", "Add").click();
cy.get(".mx_EditableItem_item").should("contain", "#gaming:localhost");
});
// Publish into the public rooms directory
cy.contains(".mx_SettingsFieldset", "Published Addresses").within(() => {
cy.get("#canonicalAlias").find(":selected").should("contain", "#gaming:localhost");
cy.get(`[aria-label="Publish this room to the public in localhost's room directory?"]`).click()
.should("have.attr", "aria-checked", "true");
});
cy.closeDialog();
cy.all([
cy.get<MatrixClient>("@bot"),
cy.get<string>("@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<MatrixClient>("@bot"),
]).then(([win, bot]) => {
bot.createRoom({
visibility: win.matrixcs.Visibility.Public,
name,
room_alias_name: "test1234",
});
});
cy.get('[role="button"][aria-label="Explore rooms"]').click();
cy.get('.mx_RoomDirectory_dialogWrapper [name="dirsearch"]').type("Unknown Room");
cy.get(".mx_RoomDirectory_dialogWrapper h5").should("contain", 'No results for "Unknown Room"');
cy.get(".mx_RoomDirectory_dialogWrapper").percySnapshotElement("Room Directory - filtered no results");
cy.get('.mx_RoomDirectory_dialogWrapper [name="dirsearch"]').type("{selectAll}{backspace}test1234");
cy.contains(".mx_RoomDirectory_dialogWrapper .mx_RoomDirectory_listItem", name)
.should("exist").as("resultRow");
cy.get(".mx_RoomDirectory_dialogWrapper").percySnapshotElement("Room Directory - filtered one result");
cy.get("@resultRow").find(".mx_AccessibleButton").contains("Join").click();
cy.url().should('contain', `/#/room/#test1234:localhost`);
});
});

View file

@ -1,117 +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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import type { UserCredentials } from "../../support/login";
describe("Device manager", () => {
let synapse: SynapseInstance | undefined;
let user: UserCredentials | undefined;
beforeEach(() => {
cy.enableLabsFeature("feature_new_device_manager");
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Alice").then(credentials => {
user = credentials;
}).then(() => {
// create some extra sessions to manage
return cy.loginUser(synapse, user.username, user.password);
}).then(() => {
return cy.loginUser(synapse, user.username, user.password);
});
});
});
afterEach(() => {
cy.stopSynapse(synapse!);
});
it("should display sessions", () => {
cy.openUserSettings("Sessions");
cy.contains('Current session').should('exist');
cy.get('[data-testid="current-session-section"]').within(() => {
cy.contains('Unverified session').should('exist');
cy.get('.mx_DeviceSecurityCard_actions [role="button"]').should('exist');
});
// current session details opened
cy.get('[data-testid="current-session-toggle-details"]').click();
cy.contains('Session details').should('exist');
// close current session details
cy.get('[data-testid="current-session-toggle-details"]').click();
cy.contains('Session details').should('not.exist');
cy.get('[data-testid="security-recommendations-section"]').within(() => {
cy.contains('Security recommendations').should('exist');
cy.get('[data-testid="unverified-devices-cta"]').should('have.text', 'View all (3)').click();
});
/**
* Other sessions section
*/
cy.contains('Other sessions').should('exist');
// filter applied after clicking through from security recommendations
cy.get('[aria-label="Filter devices"]').should('have.text', 'Show: Unverified');
cy.get('.mx_FilteredDeviceList_list').find('.mx_FilteredDeviceList_listItem').should('have.length', 3);
// select two sessions
cy.get('.mx_FilteredDeviceList_list .mx_FilteredDeviceList_listItem .mx_Checkbox').first().click();
cy.get('.mx_FilteredDeviceList_list .mx_FilteredDeviceList_listItem .mx_Checkbox').last().click();
// sign out from list selection action buttons
cy.get('[data-testid="sign-out-selection-cta"]').click();
cy.get('[data-testid="dialog-primary-button"]').click();
// list updated after sign out
cy.get('.mx_FilteredDeviceList_list').find('.mx_FilteredDeviceList_listItem').should('have.length', 1);
// security recommendation count updated
cy.get('[data-testid="unverified-devices-cta"]').should('have.text', 'View all (1)');
const sessionName = `Alice's device`;
// open the first session
cy.get('.mx_FilteredDeviceList_list .mx_FilteredDeviceList_listItem').first().within(() => {
cy.get('[aria-label="Toggle device details"]').click();
cy.contains('Session details').should('exist');
cy.get('[data-testid="device-heading-rename-cta"]').click();
cy.get('[data-testid="device-rename-input"]').type(sessionName);
cy.get('[data-testid="device-rename-submit-cta"]').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 h3').should('have.text', sessionName);
// and main list item
cy.get('.mx_DeviceTile h4').should('have.text', sessionName);
// sign out using the device details sign out
cy.get('[data-testid="device-detail-sign-out-cta"]').click();
});
// confirm the signout
cy.get('[data-testid="dialog-primary-button"]').click();
// no other sessions or security recommendations sections when only one session
cy.contains('Other sessions').should('not.exist');
cy.get('[data-testid="security-recommendations-section"]').should('not.exist');
});
});

View file

@ -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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
function seedLabs(synapse: SynapseInstance, labsVal: boolean | null): void {
cy.initTestUser(synapse, "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<string>("@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 synapse: SynapseInstance;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
it('should not migrate the lack of a labs flag', () => {
seedLabs(synapse, null);
testForVal(null);
});
it('should migrate labsHiddenRR=false as sendRR=true', () => {
seedLabs(synapse, false);
testForVal(true);
});
it('should migrate labsHiddenRR=true as sendRR=false', () => {
seedLabs(synapse, true);
testForVal(false);
});
});

View file

@ -1,399 +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.
*/
/// <reference types="cypress" />
import _ from "lodash";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { SynapseInstance } from "../../plugins/synapsedocker";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import { ProxyInstance } from "../../plugins/sliding-sync";
describe("Sliding Sync", () => {
beforeEach(() => {
cy.startSynapse("default").as("synapse").then(synapse => {
cy.startProxy(synapse).as("proxy");
});
cy.all([
cy.get<SynapseInstance>("@synapse"),
cy.get<ProxyInstance>("@proxy"),
]).then(([synapse, 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(synapse, "Sloth").then(() => {
return cy.window({ log: false }).then(() => {
cy.createRoom({ name: "Test Room" }).as("roomId");
});
});
});
});
afterEach(() => {
cy.get<SynapseInstance>("@synapse").then(cy.stopSynapse);
cy.get<ProxyInstance>("@proxy").then(cy.stopProxy);
});
// assert order
const checkOrder = (wantOrder: string[]) => {
cy.contains(".mx_RoomSublist", "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<string>(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<SynapseInstance>("@synapse").then((synapse) => {
return cy.getBot(synapse, {
displayName: "Bob",
}).as("bob");
});
// invite Bob to Test Room and accept then send a message.
cy.all([cy.get<string>("@roomId"), cy.get<MatrixClient>("@bob")]).then(([roomId, bob]) => {
return cy.inviteUser(roomId, bob.getUserId()).then(() => {
return bob.joinRoom(roomId);
});
});
};
// sanity check everything works
it("should correctly render expected messages", () => {
cy.get<string>("@roomId").then(roomId => 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.",
);
// Click "expand" link button
cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=false]").click();
});
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.contains(".mx_RoomSublist", "Apple"));
cy.createRoom({ name: "Pineapple" }).then(() => cy.contains(".mx_RoomSublist", "Pineapple"));
cy.createRoom({ name: "Orange" }).then(() => cy.contains(".mx_RoomSublist", "Orange"));
// check the rooms are in the right order
cy.get(".mx_RoomTile").should('have.length', 4); // due to the Test Room in beforeEach
checkOrder([
"Orange", "Pineapple", "Apple", "Test Room",
]);
cy.contains(".mx_RoomSublist", "Rooms").find(".mx_RoomSublist_menuButton").click({ force: true });
cy.contains("A-Z").click();
cy.get('.mx_StyledRadioButton_checked').should("contain.text", "A-Z");
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.contains(".mx_RoomSublist", "Apple"));
cy.createRoom({ name: "Pineapple" }).as("roomP").then(() => cy.contains(".mx_RoomSublist", "Pineapple"));
cy.createRoom({ name: "Orange" }).as("roomO").then(() => cy.contains(".mx_RoomSublist", "Orange"));
// Select the Test Room
cy.contains(".mx_RoomTile", "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.contains(".mx_RoomSublist", "Apple"));
cy.createRoom({ name: "Pineapple" }).as("roomP").then(() => cy.contains(".mx_RoomSublist", "Pineapple"));
cy.createRoom({ name: "Orange" }).as("roomO").then(() => cy.contains(".mx_RoomSublist", "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.contains(".mx_RoomTile", "Pineapple").click();
checkOrder([
"Orange", "Pineapple", "Apple", "Test Room",
]);
// Move Apple
bumpRoom("@roomA");
checkOrder([
"Apple", "Pineapple", "Orange", "Test Room",
]);
// Select the Test Room
cy.contains(".mx_RoomTile", "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<string>("@roomId"), cy.get<MatrixClient>("@bob")]).then(([roomId, bob]) => {
return bob.sendTextMessage(roomId, "Hello World");
});
// check that there is an unread notification (grey) as 1
cy.contains(".mx_RoomTile", "Test Room").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<string>("@roomId"), cy.get<MatrixClient>("@bob")]).then(([roomId, bob]) => {
return bob.sendTextMessage(roomId, "Hello Sloth");
});
cy.contains(".mx_RoomTile", "Test Room").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.contains(".mx_RoomTile", "Test Room").click();
cy.contains(".mx_RoomTile", "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.contains(".mx_RoomTile", "Test Room").find(".mx_RoomTile_notificationsButton").click({ force: true });
cy.contains("None").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<string>("@roomId"), cy.get<MatrixClient>("@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.contains(".mx_RoomTile", "Test Room").get(".mx_NotificationBadge").should("not.exist");
});
it("should update user settings promptly", () => {
cy.get(".mx_UserMenu_userAvatar").click();
cy.contains("All settings").click();
cy.contains("Preferences").click();
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<MatrixClient>("@bob").then((bob) => {
bobClient = bob;
return Promise.all([
bob.createRoom({ name: "Join" }),
bob.createRoom({ name: "Reject" }),
bob.createRoom({ name: "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),
]);
});
// wait for them all to be on the UI
cy.get(".mx_RoomTile").should('have.length', 4); // due to the Test Room in beforeEach
cy.contains(".mx_RoomTile", "Join").click();
cy.contains(".mx_AccessibleButton", "Accept").click();
checkOrder([
"Join", "Test Room",
]);
cy.contains(".mx_RoomTile", "Reject").click();
cy.contains(".mx_RoomView .mx_AccessibleButton", "Reject").click();
// wait for the rejected room to disappear
cy.get(".mx_RoomTile").should('have.length', 3);
// check the lists are correct
checkOrder([
"Join", "Test Room",
]);
cy.contains(".mx_RoomSublist", "Invites").find(".mx_RoomTile_title").should((elements) => {
expect(_.map(elements, (e) => {
return e.textContent;
}), "rooms are sorted").to.deep.equal(["Rescind"]);
});
// now rescind the invite
cy.get<MatrixClient>("@bob").then((bob) => {
return bob.kick(roomRescind, clientUserId);
});
// wait for the rescind to take effect and check the joined list once more
cy.get(".mx_RoomTile").should('have.length', 2);
checkOrder([
"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.contains('.mx_RoomSublist[aria-label="Favourites"] .mx_RoomTile', "Favourite DM").should("exist");
cy.contains('.mx_RoomSublist[aria-label="People"] .mx_RoomTile', "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.contains(".mx_RoomSublist", "Other Room"));
cy.get<string>("@roomId").then((roomId) => {
return cy.sendEvent(roomId, null, "m.room.message", {
body: "Hello world",
msgtype: "m.text",
});
});
// select the room
cy.contains(".mx_RoomTile", "Test Room").click();
cy.get(".mx_ReplyPreview").should("not.exist");
// click reply-to on the Hello World message
cy.contains(".mx_EventTile", "Hello world").find('.mx_AccessibleButton[aria-label="Reply"]').click(
{ force: true },
);
// check it's visible
cy.get(".mx_ReplyPreview").should("exist");
// now click Other Room
cy.contains(".mx_RoomTile", "Other Room").click();
// ensure the reply-to disappears
cy.get(".mx_ReplyPreview").should("not.exist");
// click back
cy.contains(".mx_RoomTile", "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<string>("@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.contains(".mx_RoomTile", "Test Room").click();
cy.get(".mx_ReplyPreview").should("not.exist");
// click reply-to on the Reply to me message
cy.contains(".mx_EventTile", "Reply to me").find('.mx_AccessibleButton[aria-label="Reply"]').click(
{ force: true },
);
// 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");
});
});

View file

@ -1,278 +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.
*/
/// <reference types="cypress" />
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
import { SynapseInstance } from "../../plugins/synapsedocker";
import Chainable = Cypress.Chainable;
import { UserCredentials } from "../../support/login";
function openSpaceCreateMenu(): Chainable<JQuery> {
cy.get(".mx_SpaceButton_new").click();
return cy.get(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu");
}
function openSpaceContextMenu(spaceName: string): Chainable<JQuery> {
cy.getSpacePanelButton(spaceName).rightclick();
return cy.get(".mx_SpacePanel_contextMenu");
}
function spaceCreateOptions(spaceName: string): ICreateRoomOpts {
return {
creation_content: {
type: "m.space",
},
initial_state: [{
type: "m.room.name",
content: {
name: spaceName,
},
}],
};
}
function spaceChildInitialState(roomId: string): ICreateRoomOpts["initial_state"]["0"] {
return {
type: "m.space.child",
state_key: roomId,
content: {
via: [roomId.split(":")[1]],
},
};
}
describe("Spaces", () => {
let synapse: SynapseInstance;
let user: UserCredentials;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Sue").then(_user => {
user = _user;
cy.mockClipboard();
});
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
it("should allow user to create public space", () => {
openSpaceCreateMenu().within(() => {
cy.get(".mx_SpaceCreateMenuType_public").click();
cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.selectFile("cypress/fixtures/riot.png", { force: true });
cy.get('input[label="Name"]').type("Let's have a Riot");
cy.get('input[label="Address"]').should("have.value", "lets-have-a-riot");
cy.get('textarea[label="Description"]').type("This is a space to reminisce Riot.im!");
cy.contains(".mx_AccessibleButton", "Create").click();
});
// Create the default General & Random rooms, as well as a custom "Jokes" room
cy.get('input[label="Room name"][value="General"]').should("exist");
cy.get('input[label="Room name"][value="Random"]').should("exist");
cy.get('input[placeholder="Support"]').type("Jokes");
cy.contains(".mx_AccessibleButton", "Continue").click();
// Copy matrix.to link
cy.get(".mx_SpacePublicShare_shareButton").focus().realClick();
cy.getClipboardText().should("eq", "https://matrix.to/#/#lets-have-a-riot:localhost");
// Go to space home
cy.contains(".mx_AccessibleButton", "Go to my first room").click();
// Assert rooms exist in the room list
cy.contains(".mx_RoomList .mx_RoomTile", "General").should("exist");
cy.contains(".mx_RoomList .mx_RoomTile", "Random").should("exist");
cy.contains(".mx_RoomList .mx_RoomTile", "Jokes").should("exist");
});
it("should allow user to create private space", () => {
openSpaceCreateMenu().within(() => {
cy.get(".mx_SpaceCreateMenuType_private").click();
cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.selectFile("cypress/fixtures/riot.png", { force: true });
cy.get('input[label="Name"]').type("This is not a Riot");
cy.get('input[label="Address"]').should("not.exist");
cy.get('textarea[label="Description"]').type("This is a private space of mourning Riot.im...");
cy.contains(".mx_AccessibleButton", "Create").click();
});
cy.get(".mx_SpaceRoomView_privateScope_meAndMyTeammatesButton").click();
// Create the default General & Random rooms, as well as a custom "Projects" room
cy.get('input[label="Room name"][value="General"]').should("exist");
cy.get('input[label="Room name"][value="Random"]').should("exist");
cy.get('input[placeholder="Support"]').type("Projects");
cy.contains(".mx_AccessibleButton", "Continue").click();
cy.get(".mx_SpaceRoomView").should("contain", "Invite your teammates");
cy.contains(".mx_AccessibleButton", "Skip for now").click();
// Assert rooms exist in the room list
cy.contains(".mx_RoomList .mx_RoomTile", "General").should("exist");
cy.contains(".mx_RoomList .mx_RoomTile", "Random").should("exist");
cy.contains(".mx_RoomList .mx_RoomTile", "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(() => {
cy.get(".mx_SpaceCreateMenuType_private").click();
cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.selectFile("cypress/fixtures/riot.png", { force: true });
cy.get('input[label="Address"]').should("not.exist");
cy.get('textarea[label="Description"]').type("This is a personal space to mourn Riot.im...");
cy.get('input[label="Name"]').type("This is my Riot{enter}");
});
cy.get(".mx_SpaceRoomView_privateScope_justMeButton").click();
cy.get(".mx_AddExistingToSpace_entry").click();
cy.contains(".mx_AccessibleButton", "Add").click();
cy.contains(".mx_RoomList .mx_RoomTile", "Sample Room").should("exist");
cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Sample Room").should("exist");
});
it("should allow user to invite another to a space", () => {
let bot: MatrixClient;
cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => {
bot = _bot;
});
cy.createSpace({
visibility: "public" as any,
room_alias_name: "space",
}).as("spaceId");
openSpaceContextMenu("#space:localhost").within(() => {
cy.get('.mx_SpacePanel_contextMenu_inviteButton[aria-label="Invite"]').click();
});
cy.get(".mx_SpacePublicShare").within(() => {
// Copy link first
cy.get(".mx_SpacePublicShare_shareButton").focus().realClick();
cy.getClipboardText().should("eq", "https://matrix.to/#/#space:localhost");
// Start Matrix invite flow
cy.get(".mx_SpacePublicShare_inviteButton").click();
});
cy.get(".mx_InviteDialog_other").within(() => {
cy.get('input[type="text"]').type(bot.getUserId());
cy.contains(".mx_AccessibleButton", "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(synapse, { 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().find('.mx_SpaceButton[aria-label="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<string>("@roomId1"),
cy.get<string>("@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(() => {
cy.contains(".mx_SpaceHierarchy_roomTile", "Music").should("exist");
cy.contains(".mx_SpaceHierarchy_roomTile", "Gaming").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");
});
cy.get('.mx_SpacePanel .mx_SpaceButton[aria-label="Root Space"]').should("exist");
cy.get('.mx_SpacePanel .mx_SpaceButton[aria-label="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.get(".mx_SpaceButton_toggleCollapse").click({ force: true });
cy.get(".mx_SpacePanel:not(.collapsed)").should("exist");
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] });
});
});

View file

@ -1,482 +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.
*/
/// <reference types="cypress" />
import { MatrixClient } from "../../global";
import { SynapseInstance } from "../../plugins/synapsedocker";
import Chainable = Cypress.Chainable;
import Loggable = Cypress.Loggable;
import Timeoutable = Cypress.Timeoutable;
import Withinable = Cypress.Withinable;
import Shadow = Cypress.Shadow;
export 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<Loggable & Timeoutable & Withinable & Shadow>
): Chainable<JQuery<HTMLElement>>;
spotlightDialog(
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>
): Chainable<JQuery<HTMLElement>>;
spotlightFilter(
filter: Filter | null,
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>
): Chainable<JQuery<HTMLElement>>;
spotlightSearch(
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>
): Chainable<JQuery<HTMLElement>>;
spotlightResults(
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>
): Chainable<JQuery<HTMLElement>>;
roomHeaderName(
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>
): Chainable<JQuery<HTMLElement>>;
startDM(name: string): Chainable<void>;
}
}
}
Cypress.Commands.add("openSpotlightDialog", (
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>,
): Chainable<JQuery<HTMLElement>> => {
cy.get('.mx_RoomSearch_spotlightTrigger', options).click({ force: true });
return cy.spotlightDialog(options);
});
Cypress.Commands.add("spotlightDialog", (
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>,
): Chainable<JQuery<HTMLElement>> => {
return cy.get('[role=dialog][aria-label="Search Dialog"]', options);
});
Cypress.Commands.add("spotlightFilter", (
filter: Filter | null,
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>,
): Chainable<JQuery<HTMLElement>> => {
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<Loggable & Timeoutable & Withinable & Shadow>,
): Chainable<JQuery<HTMLElement>> => {
return cy.get(".mx_SpotlightDialog_searchBox input", options);
});
Cypress.Commands.add("spotlightResults", (
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>,
): Chainable<JQuery<HTMLElement>> => {
return cy.get(".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option", options);
});
Cypress.Commands.add("roomHeaderName", (
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>,
): Chainable<JQuery<HTMLElement>> => {
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.get(".mx_BasicMessageComposer_input")
.should("have.focus")
.type("Hey!{enter}");
// The DM room is created at this point, this can take a little bit of time
cy.contains(".mx_EventTile_body", "Hey!", { timeout: 30000 });
cy.contains(".mx_RoomSublist[aria-label=People]", name);
});
describe("Spotlight", () => {
let synapse: SynapseInstance;
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.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Jim").then(() =>
cy.getBot(synapse, { displayName: bot1Name }).then(_bot1 => {
bot1 = _bot1;
}),
).then(() =>
cy.getBot(synapse, { 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(_room1Id => {
room1Id = _room1Id;
bot1.joinRoom(room1Id);
cy.visit("/#/room/" + 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.get('.mx_RoomSublist_skeletonUI').should('not.exist'),
);
});
});
afterEach(() => {
cy.visit("/#/home");
cy.stopSynapse(synapse);
});
it("should be able to add and remove filters via keyboard", () => {
cy.openSpotlightDialog().within(() => {
cy.spotlightSearch().type("{downArrow}");
cy.get("#mx_SpotlightDialog_button_explorePublicRooms").should("have.attr", "aria-selected", "true");
cy.spotlightSearch().type("{enter}");
cy.get(".mx_SpotlightDialog_filter").should("contain", "Public rooms");
cy.spotlightSearch().type("{backspace}");
cy.get(".mx_SpotlightDialog_filter").should("not.exist");
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.wait(3000); // wait for the dialog code to settle
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.wait(3000); // wait for the dialog code to settle
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.wait(3000); // wait for the dialog code to settle
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.wait(3000); // wait for the dialog code to settle
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.get(".mx_RoomPreviewBar_actions .mx_AccessibleButton").click();
cy.roomHeaderName().should("contain", room3Name);
});
});
// TODO: We currently cant test finding rooms on other homeservers/other protocols
// We obviously dont have federation or bridges in cypress tests
/*
const room3Name = "Matrix HQ";
const room3Id = "#matrix:matrix.org";
it("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.wait(3000); // wait for the dialog code to settle
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.wait(3000); // wait for the dialog code to settle
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 weve 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.wait(3000); // wait for the dialog code to settle
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.get(".mx_BasicMessageComposer_input")
.click()
.should("have.focus")
.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.get(".mx_RoomSublist[aria-label=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.get(".mx_RoomSublist[aria-label=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.get('[role=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");
});
});
});
});
});

View file

@ -1,279 +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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import { MatrixClient } from "../../global";
function markWindowBeforeReload(): void {
// mark our window object to "know" when it gets reloaded
cy.window().then(w => w.beforeReload = true);
}
describe("Threads", () => {
let synapse: SynapseInstance;
beforeEach(() => {
// Default threads to ON for this spec
cy.enableLabsFeature("feature_thread");
cy.window().then(win => {
win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests
});
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Tom");
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
it("should reload when enabling threads beta", () => {
markWindowBeforeReload();
// Turn off
cy.openUserSettings("Labs").within(() => {
// initially the new property is there
cy.window().should("have.prop", "beforeReload", true);
cy.leaveBeta("Threads");
cy.wait(1000);
// after reload the property should be gone
cy.window().should("not.have.prop", "beforeReload");
});
cy.get(".mx_MatrixChat", { timeout: 15000 }); // wait for the app
markWindowBeforeReload();
// Turn on
cy.openUserSettings("Labs").within(() => {
// initially the new property is there
cy.window().should("have.prop", "beforeReload", true);
cy.joinBeta("Threads");
cy.wait(1000);
// after reload the property should be gone
cy.window().should("not.have.prop", "beforeReload");
});
});
it("should be usable for a conversation", () => {
let bot: MatrixClient;
cy.getBot(synapse, {
displayName: "BotBob",
autoAcceptInvites: false,
}).then(_bot => {
bot = _bot;
});
let roomId: string;
cy.createRoom({}).then(_roomId => {
roomId = _roomId;
cy.inviteUser(roomId, bot.getUserId());
bot.joinRoom(roomId);
cy.visit("/#/room/" + roomId);
});
// User sends message
cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}");
// Wait for message to send, get its ID and save as @threadId
cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot")
.invoke("attr", "data-scroll-tokens").as("threadId");
// Bot starts thread
cy.get<string>("@threadId").then(threadId => {
bot.sendMessage(roomId, threadId, {
body: "Hello there",
msgtype: "m.text",
});
});
// User asserts timeline thread summary visible & clicks it
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob");
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Hello there");
cy.get(".mx_RoomView_body .mx_ThreadSummary").click();
// User responds in thread
cy.get(".mx_ThreadView .mx_BasicMessageComposer_input").type("Test{enter}");
// User asserts summary was updated correctly
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "Tom");
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Test");
// User reacts to message instead
cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Hello there")
.find('[aria-label="React"]').click({ force: true }); // Cypress has no ability to hover
cy.get(".mx_EmojiPicker").within(() => {
cy.get('input[type="text"]').type("wave");
cy.contains('[role="menuitem"]', "👋").click();
});
// User redacts their prior response
cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Test")
.find('[aria-label="Options"]').click({ force: true }); // Cypress has no ability to hover
cy.get(".mx_IconizedContextMenu").within(() => {
cy.contains('[role="menuitem"]', "Remove").click();
});
cy.get(".mx_TextInputDialog").within(() => {
cy.contains(".mx_Dialog_primary", "Remove").click();
});
// User asserts summary was updated correctly
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob");
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Hello there");
// User closes right panel after clicking back to thread list
cy.get(".mx_ThreadView .mx_BaseCard_back").click();
cy.get(".mx_ThreadPanel .mx_BaseCard_close").click();
// Bot responds to thread
cy.get<string>("@threadId").then(threadId => {
bot.sendMessage(roomId, threadId, {
body: "How are things?",
msgtype: "m.text",
});
});
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob");
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "How are things?");
// User asserts thread list unread indicator
cy.get('.mx_HeaderButtons [aria-label="Threads"]').should("have.class", "mx_RightPanel_headerButton_unread");
// User opens thread list
cy.get('.mx_HeaderButtons [aria-label="Threads"]').click();
// User asserts thread with correct root & latest events & unread dot
cy.get(".mx_ThreadPanel .mx_EventTile_last").within(() => {
cy.get(".mx_EventTile_body").should("contain", "Hello Mr. Bot");
cy.get(".mx_ThreadSummary_content").should("contain", "How are things?");
// User opens thread via threads list
cy.get(".mx_EventTile_line").click();
});
// User responds & asserts
cy.get(".mx_ThreadView .mx_BasicMessageComposer_input").type("Great!{enter}");
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "Tom");
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Great!");
// User edits & asserts
cy.contains(".mx_ThreadView .mx_EventTile_last .mx_EventTile_line", "Great!").within(() => {
cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover
cy.get(".mx_BasicMessageComposer_input").type(" How about yourself?{enter}");
});
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "Tom");
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content")
.should("contain", "Great! How about yourself?");
// User closes right panel
cy.get(".mx_ThreadView .mx_BaseCard_close").click();
// Bot responds to thread and saves the id of their message to @eventId
cy.get<string>("@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 .mx_ThreadSummary_sender").should("contain", "BotBob");
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content")
.should("contain", "I'm very good thanks");
// Bot edits their latest event
cy.get<string>("@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 .mx_ThreadSummary_sender").should("contain", "BotBob");
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content")
.should("contain", "I'm very good thanks :)");
});
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 .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}");
// Create thread
cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot")
.realHover().find(".mx_MessageActionBar_threadButton").click();
cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1);
cy.openMessageComposerOptions(true).find(`[aria-label="Voice Message"]`).click();
cy.wait(3000);
cy.getComposer(true).find(".mx_MessageComposer_sendMessage").click();
cy.get(".mx_ThreadView .mx_MVoiceMessageBody").should("have.length", 1);
});
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 .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}");
// Create thread
cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot")
.realHover().find(".mx_MessageActionBar_threadButton").click();
cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1);
// Send message to thread
cy.get(".mx_BaseCard .mx_BasicMessageComposer_input").type("Hello Mr. User{enter}");
cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. User");
// Close thread
cy.get(".mx_BaseCard_close").click();
// Open existing thread
cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot")
.realHover().find(".mx_MessageActionBar_threadButton").click();
cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1);
cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. Bot");
cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. User");
});
});

View file

@ -1,364 +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.
*/
/// <reference types="cypress" />
import { MessageEvent } from "matrix-events-sdk";
import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests";
import type { EventType } from "matrix-js-sdk/src/@types/event";
import { SynapseInstance } from "../../plugins/synapsedocker";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
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<JQuery> => {
return cy.get(".mx_EventTile").filter((_i, e) => e.getElementsByClassName("mx_EventTile_body").length > 0);
};
const expectDisplayName = (e: JQuery<HTMLElement>, displayName: string): void => {
expect(e.find(".mx_DisambiguatedProfile_displayName").text()).to.equal(displayName);
};
const expectAvatar = (e: JQuery<HTMLElement>, 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<ISendEventResponse> => {
return cy.sendEvent(
roomId,
null,
"m.room.message" as EventType,
MessageEvent.from("Message", html ? "<b>Message</b>" : undefined).serialize().content,
);
};
describe("Timeline", () => {
let synapse: SynapseInstance;
let roomId: string;
let oldAvatarUrl: string;
let newAvatarUrl: string;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, OLD_NAME).then(() =>
cy.createRoom({ name: ROOM_NAME }).then(_room1Id => {
roomId = _room1Id;
}),
);
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
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("message displaying", () => {
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_Spinner").should("not.exist");
cy.percySnapshot("Configured room on IRC layout");
});
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_toggle[aria-expanded=false]").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) + 10px + var(--icon-width))
// = 80 + 10 + 14 = 104px
cy.get(".mx_EventTile[data-layout=irc].mx_EventTile_info:first-of-type .mx_EventTile_line")
.should('have.css', "margin-inline-start", "104px")
.should('have.css', "inset-inline-start", "0px");
cy.get(".mx_Spinner").should("not.exist");
// Exclude timestamp from snapshot
const percyCSS = ".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp "
+ "{ visibility: hidden !important; }";
cy.percySnapshot("Event line with inline start margin on IRC layout", { percyCSS });
cy.checkA11y();
});
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
cy.contains(".mx_RoomView_body .mx_EventTile .mx_EventTile_line", "Message").within(() => {
cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover
cy.get(".mx_BasicMessageComposer_input").type("Edit{enter}");
});
cy.contains(".mx_EventTile[data-scroll-tokens]", "MessageEdit").should("exist");
// Click timestamp to highlight hidden event line
cy.get(".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp").click();
// Exclude timestamp from snapshot
const percyCSS = ".mx_RoomView_body .mx_EventTile .mx_MessageTimestamp "
+ "{ 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');
cy.percySnapshot("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');
cy.percySnapshot("Hidden event line with padding on modern layout", { percyCSS });
});
it("should click top left of view source event toggle", () => {
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
cy.contains(".mx_RoomView_body .mx_EventTile .mx_EventTile_line", "Message").within(() => {
cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover
cy.get(".mx_BasicMessageComposer_input").type("Edit{enter}");
});
cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "MessageEdit").should("exist");
// Click top left of the event toggle, which should not be covered by MessageActionBar's safe area
cy.get(".mx_EventTile .mx_ViewSourceEvent").should("exist").realHover().within(() => {
cy.get(".mx_ViewSourceEvent_toggle").click('topLeft', { force: false });
});
// Make sure the expand toggle worked
cy.get(".mx_EventTile .mx_ViewSourceEvent_expanded .mx_ViewSourceEvent_toggle").should("be.visible");
});
it("should click 'collapse' link button on the first hovered info event line on bubble layout", () => {
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");
// Click "expand" link button
cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=false]").click();
// Click "collapse" link button on the first hovered info event line
cy.get(".mx_GenericEventListSummary_unstyledList .mx_EventTile_info:first-of-type").realHover();
cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=true]").click({ force: false });
// Make sure "collapse" link button worked
cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=false]").should("exist");
});
it("should highlight search result words regardless of formatting", () => {
sendEvent(roomId);
sendEvent(roomId, true);
cy.visit("/#/room/" + roomId);
cy.get(".mx_RoomHeader_searchButton").click();
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,
MessageEvent.from("https://call.element.io/").serialize().content,
);
cy.visit("/#/room/" + roomId);
cy.get(".mx_LinkPreviewWidget").should("exist").should("contain.text", "Element Call");
cy.wait("@preview_url");
cy.wait("@mxc");
cy.checkA11y();
cy.get(".mx_EventTile_last").percySnapshotElement("URL Preview", {
widths: [800, 400],
});
});
});
describe("message sending", () => {
const MESSAGE = "Hello world";
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").within(() => {
cy.get('[aria-label="Reply"]').click({ force: true }); // Cypress has no ability to hover
});
};
it("can reply with a text message", () => {
const reply = "Reply";
viewRoomSendMessageAndSetupReply();
cy.getComposer().type(`${reply}{enter}`);
cy.get(".mx_RoomView_body .mx_EventTile .mx_EventTile_line .mx_ReplyTile .mx_MTextBody")
.should("contain", MESSAGE);
cy.contains(".mx_RoomView_body .mx_EventTile > .mx_EventTile_line > .mx_MTextBody", reply)
.should("have.length", 1);
});
it("can reply with a voice message", () => {
viewRoomSendMessageAndSetupReply();
cy.openMessageComposerOptions().within(() => {
cy.get(`[aria-label="Voice Message"]`).click();
});
cy.wait(3000);
cy.get(".mx_RoomView_body .mx_MessageComposer .mx_MessageComposer_sendMessage").click();
cy.get(".mx_RoomView_body .mx_EventTile .mx_EventTile_line .mx_ReplyTile .mx_MTextBody")
.should("contain", MESSAGE);
cy.get(".mx_RoomView_body .mx_EventTile > .mx_EventTile_line > .mx_MVoiceMessageBody")
.should("have.length", 1);
});
});
});

View file

@ -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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import Chainable = Cypress.Chainable;
function assertNoToasts(): void {
cy.get(".mx_Toast_toast").should("not.exist");
}
function getToast(expectedTitle: string): Chainable<JQuery> {
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 synapse: SynapseInstance;
afterEach(() => {
cy.stopSynapse(synapse);
});
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.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "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.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "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();
});
});
});

View file

@ -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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
describe("Update", () => {
let synapse: SynapseInstance;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
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(synapse, "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);
});
});
});

View file

@ -1,47 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import type { UserCredentials } from "../../support/login";
describe("User Menu", () => {
let synapse: SynapseInstance;
let user: UserCredentials;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Jeff").then(credentials => {
user = credentials;
});
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
it("should contain our name & userId", () => {
cy.get('[aria-label="User menu"]').click();
cy.get(".mx_UserMenu_contextMenu").within(() => {
cy.get(".mx_UserMenu_contextMenu_displayName").should("contain", "Jeff");
cy.get(".mx_UserMenu_contextMenu_userId").should("contain", user.userId);
});
});
});

View file

@ -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.
*/
/// <reference types="cypress" />
import { MatrixClient } from "../../global";
import { SynapseInstance } from "../../plugins/synapsedocker";
describe("User Onboarding (new user)", () => {
let synapse: SynapseInstance;
const bot1Name = "BotBob";
let bot1: MatrixClient;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "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(synapse, { displayName: bot1Name }).then(_bot1 => {
bot1 = _bot1;
});
cy.get('.mx_UserOnboardingPage').should('exist');
cy.get('.mx_UserOnboardingButton').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.stopSynapse(synapse);
});
it("page is shown and preference exists", () => {
cy.get('.mx_UserOnboardingPage')
.percySnapshotElement("User onboarding page");
cy.openUserSettings("Preferences");
cy.contains("Show shortcut to welcome checklist above the room list").should("exist");
});
it("app download dialog", () => {
cy.contains(".mx_UserOnboardingTask_action", "Download apps").click();
cy.get('[role=dialog]')
.contains("#mx_BaseDialog_title", "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.contains(".mx_UserOnboardingTask_action", "Find friends");
expect(findPeopleAction).to.exist;
findPeopleAction.click();
cy.get(".mx_InviteDialog_editor input").type(bot1.getUserId());
cy.get(".mx_InviteDialog_buttonAndSpinner").click();
cy.get(".mx_InviteDialog_buttonAndSpinner").should("not.exist");
const message = "Hi!";
cy.get(".mx_SendMessageComposer").type(`${message}!{enter}`);
cy.contains(".mx_MTextBody.mx_EventTile_content", message);
cy.visit("/#/home");
cy.get('.mx_UserOnboardingPage').should('exist');
cy.get('.mx_UserOnboardingButton').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);
});
});
});

View file

@ -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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
describe("User Onboarding (old user)", () => {
let synapse: SynapseInstance;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "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.stopSynapse(synapse);
});
it("page and preference are hidden", () => {
cy.get('.mx_UserOnboardingPage').should('not.exist');
cy.get('.mx_UserOnboardingButton').should('not.exist');
cy.openUserSettings("Preferences");
cy.contains("Show shortcut to welcome page above the room list").should("not.exist");
});
});

View file

@ -1,51 +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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import { MatrixClient } from "../../global";
describe("UserView", () => {
let synapse: SynapseInstance;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Violet");
cy.getBot(synapse, { displayName: "Usman" }).as("bot");
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
it("should render the user view as expected", () => {
cy.get<MatrixClient>("@bot").then(bot => {
cy.visit(`/#/user/${bot.getUserId()}`);
});
cy.get("#mx_RightPanel .mx_UserInfo_profile h2").should("contain", "Usman");
cy.get(".mx_RightPanel .mx_Spinner").should("not.exist"); // wait for spinners to finish
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],
});
});
});

View file

@ -1,121 +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 { SynapseInstance } from "../../plugins/synapsedocker";
const ROOM_NAME = 'Test Room';
const WIDGET_ID = "fake-widget";
const WIDGET_HTML = `
<html lang="en">
<head>
<title>Fake Widget</title>
</head>
<body>
Hello World
</body>
</html>
`;
describe('Widget Layout', () => {
let widgetUrl: string;
let synapse: SynapseInstance;
let roomId: string;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "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<string>("@widgetEventSent"),
cy.get<string>("@layoutEventSent"),
]).then(() => {
// open the room
cy.viewRoomByName(ROOM_NAME);
});
});
afterEach(() => {
cy.stopSynapse(synapse);
cy.stopWebServers();
});
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);
});
});

View file

@ -1,163 +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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker";
const STICKER_PICKER_WIDGET_NAME = "Fake Stickers";
const STICKER_NAME = "Test Sticker";
const ROOM_NAME_1 = "Sticker Test";
const ROOM_NAME_2 = "Sticker Test Two";
const STICKER_MESSAGE = JSON.stringify({
action: "m.sticker",
api: "fromWidget",
data: {
name: "teststicker",
description: STICKER_NAME,
file: "test.png",
content: {
body: STICKER_NAME,
msgtype: "m.sticker",
url: "mxc://somewhere",
},
},
requestId: "1",
widgetId: STICKER_PICKER_WIDGET_ID,
});
const WIDGET_HTML = `
<html lang="en">
<head>
<title>Fake Sticker Picker</title>
<script>
window.onmessage = ev => {
if (ev.data.action === 'capabilities') {
window.parent.postMessage(Object.assign({
response: {
capabilities: ["m.sticker"]
},
}, ev.data), '*');
}
};
</script>
</head>
<body>
<button name="Send" id="sendsticker">Press for sticker</button>
<script>
document.getElementById('sendsticker').onclick = () => {
window.parent.postMessage(${STICKER_MESSAGE}, '*')
};
</script>
</body>
</html>
`;
function openStickerPicker() {
cy.get('.mx_MessageComposer_buttonMenu').click();
cy.get('#stickersButton').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();
});
// Sticker picker should close itself after sending.
cy.get(".mx_AppTileFullWidth#stickers").should('not.exist');
}
function expectTimelineSticker(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<HTMLImageElement>(`img[alt="${STICKER_NAME}"]`)
.should("have.attr", "src")
.and("match", /thumbnail\/somewhere\?/);
}
describe("Stickers", () => {
// 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 synapse: SynapseInstance;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Sally");
});
cy.serveHtmlFile(WIDGET_HTML).then(url => {
stickerPickerUrl = url;
});
});
afterEach(() => {
cy.stopSynapse(synapse);
cy.stopWebServers();
});
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", {
[STICKER_PICKER_WIDGET_ID]: {
content: {
type: "m.stickerpicker",
name: STICKER_PICKER_WIDGET_NAME,
url: stickerPickerUrl,
},
id: STICKER_PICKER_WIDGET_ID,
},
}).as("stickers");
cy.all([
cy.get<string>("@roomId1"),
cy.get<string>("@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);
});
});
});

View file

@ -1,201 +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.
*/
/// <reference types="cypress" />
import { IWidget } from "matrix-widget-api/src/interfaces/IWidget";
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { SynapseInstance } from "../../plugins/synapsedocker";
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 = `
<html lang="en">
<head>
<title>Demo Widget</title>
<script>
window.onmessage = ev => {
if (ev.data.action === 'capabilities') {
window.parent.postMessage(Object.assign({
response: {
capabilities: []
},
}, ev.data), '*');
}
};
</script>
</head>
<body>
<button id="demo">Demo</button>
</body>
</html>
`;
// mostly copied from src/utils/WidgetUtils.waitForRoomWidget with small modifications
function waitForRoomWidget(win: Cypress.AUTWindow, widgetId: string, roomId: string, add: boolean): Promise<void> {
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 synapse: SynapseInstance;
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<string>("@powerLevelsChanged"),
cy.get<string>("@botJoined"),
cy.get<string>("@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_LegacyCallView_pip").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_LegacyCallView_pip").should("not.exist");
});
});
});
});
});
}
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Mike").then(_user => {
user = _user;
});
cy.getBot(synapse, { displayName: "Bot", autoAcceptInvites: false }).then(_bot => {
bot = _bot;
});
});
cy.serveHtmlFile(DEMO_WIDGET_HTML).then(url => {
demoWidgetUrl = url;
});
});
afterEach(() => {
cy.stopSynapse(synapse);
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');
});
});

73
cypress/global.d.ts vendored
View file

@ -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 };

View file

@ -1,157 +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.
*/
/// <reference types="cypress" />
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 function dockerRun(opts: {
image: string;
containerName: string;
params?: string[];
cmd?: string;
}): Promise<string> {
const userInfo = os.userInfo();
const params = opts.params ?? [];
if (params?.includes("-v") && userInfo.uid >= 0) {
// On *nix we run the docker container as our uid:gid otherwise cleaning it up its media_store can be difficult
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<string>((resolve, reject) => {
childProcess.execFile("docker", args, (err, stdout) => {
if (err) reject(err);
resolve(stdout.trim());
});
});
}
export function dockerExec(args: {
containerId: string;
params: string[];
}): Promise<void> {
return new Promise<void>((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<void> {
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<void>((resolve) => {
childProcess.spawn("docker", [
"logs",
args.containerId,
], {
stdio: ["ignore", stdoutFile, stderrFile],
}).once('close', resolve);
});
if (args.stdoutFile) await fse.close(<number>stdoutFile);
if (args.stderrFile) await fse.close(<number>stderrFile);
}
export function dockerStop(args: {
containerId: string;
}): Promise<void> {
return new Promise<void>((resolve, reject) => {
childProcess.execFile('docker', [
"stop",
args.containerId,
], err => {
if (err) reject(err);
resolve();
});
});
}
export function dockerRm(args: {
containerId: string;
}): Promise<void> {
return new Promise<void>((resolve, reject) => {
childProcess.execFile('docker', [
"rm",
args.containerId,
], err => {
if (err) reject(err);
resolve();
});
});
}
export function dockerIp(args: {
containerId: string;
}): Promise<string> {
return new Promise<string>((resolve, reject) => {
childProcess.execFile('docker', [
"inspect",
"-f", "{{ .NetworkSettings.IPAddress }}",
args.containerId,
], (err, stdout) => {
if (err) reject(err);
else resolve(stdout.trim());
});
});
}
/**
* @type {Cypress.PluginConfig}
*/
export function docker(on: PluginEvents, config: PluginConfigOptions) {
on("task", {
dockerRun,
dockerExec,
dockerLogs,
dockerStop,
dockerRm,
dockerIp,
});
}

View file

@ -1,36 +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.
*/
/// <reference types="cypress" />
import PluginEvents = Cypress.PluginEvents;
import PluginConfigOptions = Cypress.PluginConfigOptions;
import { synapseDocker } from "./synapsedocker";
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);
slidingSyncProxyDocker(on, config);
webserver(on, config);
log(on, config);
}

View file

@ -1,128 +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.
*/
/// <reference types="cypress" />
import PluginEvents = Cypress.PluginEvents;
import PluginConfigOptions = Cypress.PluginConfigOptions;
import { dockerExec, dockerIp, dockerRun, dockerStop } from "../docker";
import { getFreePort } from "../utils/port";
import { SynapseInstance } from "../synapsedocker";
// A cypress plugins to add command to start & stop https://github.com/matrix-org/sliding-sync
export interface ProxyInstance {
containerId: string;
postgresId: string;
port: number;
}
const instances = new Map<string, ProxyInstance>();
const PG_PASSWORD = "p4S5w0rD";
async function proxyStart(synapse: SynapseInstance): Promise<ProxyInstance> {
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 synapseIp = await dockerIp({ containerId: synapse.synapseId });
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...");
const containerId = await dockerRun({
image: "ghcr.io/matrix-org/sliding-sync-proxy:v0.6.0",
containerName: "react-sdk-cypress-sliding-sync-proxy",
params: [
"--rm",
"-p", `${port}:8008/tcp`,
"-e", "SYNCV3_SECRET=bwahahaha",
"-e", `SYNCV3_SERVER=http://${synapseIp}: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<void> {
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) {
on("task", {
proxyStart,
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);
}
});
}

View file

@ -1,188 +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.
*/
/// <reference types="cypress" />
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";
// A cypress plugins to add command to start & stop synapses in
// docker with preset templates.
interface SynapseConfig {
configDir: string;
registrationSecret: string;
// Synapse must be configured with its public_baseurl so we have to allocate a port & url at this stage
baseUrl: string;
port: number;
}
export interface SynapseInstance extends SynapseConfig {
synapseId: string;
}
const synapses = new Map<string, SynapseInstance>();
function randB64Bytes(numBytes: number): string {
return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, "");
}
async function cfgDirFromTemplate(template: string): Promise<SynapseConfig> {
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<SynapseInstance> {
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: SynapseInstance = { synapseId, ...synCfg };
synapses.set(synapseId, synapse);
return synapse;
}
async function synapseStop(id: string): Promise<void> {
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"));
});
}

View file

@ -1,84 +0,0 @@
server_name: "localhost"
pid_file: /data/homeserver.pid
public_baseurl: "{{PUBLIC_BASEURL}}"
listeners:
- port: 8008
tls: false
bind_addresses: ['::']
type: http
x_forwarded: true
resources:
- names: [client, federation, consent]
compress: false
database:
name: "sqlite3"
args:
database: ":memory:"
log_config: "/data/log.config"
rc_messages_per_second: 10000
rc_message_burst_count: 10000
rc_registration:
per_second: 10000
burst_count: 10000
rc_login:
address:
per_second: 10000
burst_count: 10000
account:
per_second: 10000
burst_count: 10000
failed_attempts:
per_second: 10000
burst_count: 10000
media_store_path: "/data/media_store"
uploads_path: "/data/uploads"
enable_registration: true
enable_registration_without_verification: true
disable_msisdn_registration: false
registration_shared_secret: "{{REGISTRATION_SECRET}}"
report_stats: false
macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
form_secret: "{{FORM_SECRET}}"
signing_key_path: "/data/localhost.signing.key"
email:
enable_notifs: false
smtp_host: "localhost"
smtp_port: 25
smtp_user: "exampleusername"
smtp_pass: "examplepassword"
require_transport_security: False
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>"
app_name: Matrix
notif_template_html: notif_mail.html
notif_template_text: notif_mail.txt
notif_for_new_users: True
client_base_url: "http://localhost/element"
user_consent:
template_dir: /data/res/templates/privacy
version: 1.0
server_notice_content:
msgtype: m.text
body: >-
To continue using this homeserver you must review and agree to the
terms and conditions at %(consent_uri)s
send_server_notice_to_guests: True
block_events_error: >-
To continue using this homeserver you must review and agree to the
terms and conditions at %(consent_uri)s
require_at_registration: true
server_notices:
system_mxid_localpart: notices
system_mxid_display_name: "Server Notices"
system_mxid_avatar_url: "mxc://localhost:5005/oumMVlgDnLYFaPVkExemNVVZ"
room_name: "Server Notices"
trusted_key_servers:
- server_name: "matrix.org"
suppress_key_server_warning: true

View file

@ -1,23 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Test Privacy policy</title>
</head>
<body>
{% if has_consented %}
<p>
Thank you, you've already accepted the license.
</p>
{% else %}
<p>
Please accept the license!
</p>
<form method="post" action="consent">
<input type="hidden" name="v" value="{{version}}"/>
<input type="hidden" name="u" value="{{user}}"/>
<input type="hidden" name="h" value="{{userhmac}}"/>
<input type="submit" value="Sure thing!"/>
</form>
{% endif %}
</body>
</html>

View file

@ -1,9 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Test Privacy policy</title>
</head>
<body>
<p>Danke schon</p>
</body>
</html>

View file

@ -1,76 +0,0 @@
server_name: "localhost"
pid_file: /data/homeserver.pid
public_baseurl: "{{PUBLIC_BASEURL}}"
listeners:
- port: 8008
tls: false
bind_addresses: ['::']
type: http
x_forwarded: true
resources:
- names: [client]
compress: false
database:
name: "sqlite3"
args:
database: ":memory:"
log_config: "/data/log.config"
rc_messages_per_second: 10000
rc_message_burst_count: 10000
rc_registration:
per_second: 10000
burst_count: 10000
rc_joins:
local:
per_second: 9999
burst_count: 9999
remote:
per_second: 9999
burst_count: 9999
rc_joins_per_room:
per_second: 9999
burst_count: 9999
rc_3pid_validation:
per_second: 1000
burst_count: 1000
rc_invites:
per_room:
per_second: 1000
burst_count: 1000
per_user:
per_second: 1000
burst_count: 1000
rc_login:
address:
per_second: 10000
burst_count: 10000
account:
per_second: 10000
burst_count: 10000
failed_attempts:
per_second: 10000
burst_count: 10000
media_store_path: "/data/media_store"
uploads_path: "/data/uploads"
enable_registration: true
enable_registration_without_verification: true
disable_msisdn_registration: false
registration_shared_secret: "{{REGISTRATION_SECRET}}"
report_stats: false
macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
form_secret: "{{FORM_SECRET}}"
signing_key_path: "/data/localhost.signing.key"
trusted_key_servers:
- server_name: "matrix.org"
suppress_key_server_warning: true
ui_auth:
session_timeout: "300s"

View file

@ -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.
*/
/// <reference types="cypress" />
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);
}

View file

@ -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.
*/
/// <reference types="cypress" />
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<string, any>): Chainable<AUTWindow>;
}
}
}
Cypress.Commands.add("tweakConfig", (tweaks: Record<string, any>): Chainable<AUTWindow> => {
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 { };

View file

@ -1,61 +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.
*/
/// <reference types="cypress" />
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,
},
...options.rules,
},
}, violationCallback ?? terminalLog, skipFailures);
});

View file

@ -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.
*/
/// <reference types="cypress" />
import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { SynapseInstance } from "../plugins/synapsedocker";
import Chainable = Cypress.Chainable;
interface CreateBotOpts {
/**
* Whether the bot should automatically accept all invites.
*/
autoAcceptInvites?: boolean;
/**
* The display name to give to that bot user
*/
displayName?: string;
/**
* Whether or not to start the syncing client.
*/
startClient?: boolean;
}
const defaultCreateBotOptions = {
autoAcceptInvites: true,
startClient: true,
} as CreateBotOpts;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Returns a new Bot instance
* @param synapse the instance on which to register the bot user
* @param opts create bot options
*/
getBot(synapse: SynapseInstance, opts: CreateBotOpts): Chainable<MatrixClient>;
/**
* Let a bot join a room
* @param cli The bot's MatrixClient
* @param roomId ID of the room to join
*/
botJoinRoom(cli: MatrixClient, roomId: string): Chainable<Room>;
/**
* Let a bot join a room by name
* @param cli The bot's MatrixClient
* @param roomName Name of the room to join
*/
botJoinRoomByName(cli: MatrixClient, roomName: string): Chainable<Room>;
/**
* Send a message as a bot into a room
* @param cli The bot's MatrixClient
* @param roomId ID of the room to join
* @param message the message body to send
*/
botSendMessage(cli: MatrixClient, roomId: string, message: string): Chainable<ISendEventResponse>;
}
}
}
Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): Chainable<MatrixClient> => {
opts = Object.assign({}, defaultCreateBotOptions, opts);
const username = Cypress._.uniqueId("userId_");
const password = Cypress._.uniqueId("password_");
return cy.registerUser(synapse, username, password, opts.displayName).then(credentials => {
return cy.window({ log: false }).then(win => {
const cli = new win.matrixcs.MatrixClient({
baseUrl: synapse.baseUrl,
userId: credentials.userId,
deviceId: credentials.deviceId,
accessToken: credentials.accessToken,
store: new win.matrixcs.MemoryStore(),
scheduler: new win.matrixcs.MatrixScheduler(),
cryptoStore: new win.matrixcs.MemoryCryptoStore(),
});
if (opts.autoAcceptInvites) {
cli.on(win.matrixcs.RoomMemberEvent.Membership, (event, member) => {
if (member.membership === "invite" && member.userId === cli.getUserId()) {
cli.joinRoom(member.roomId);
}
});
}
if (!opts.startClient) {
return cy.wrap(cli);
}
return cy.wrap(
cli.initCrypto()
.then(() => cli.setGlobalErrorOnUnknownDevices(false))
.then(() => cli.startClient())
.then(() => cli.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async func => { await func({}); },
}))
.then(() => cli),
);
});
});
});
Cypress.Commands.add("botJoinRoom", (cli: MatrixClient, roomId: string): Chainable<Room> => {
return cy.wrap(cli.joinRoom(roomId));
});
Cypress.Commands.add("botJoinRoomByName", (cli: MatrixClient, roomName: string): Chainable<Room> => {
const room = cli.getRooms().find((r) => r.getDefaultRoomName(cli.getUserId()) === roomName);
if (room) {
return cy.botJoinRoom(cli, room.roomId);
}
return cy.wrap(Promise.reject(`Bot room join failed. Cannot find room '${roomName}'`));
});
Cypress.Commands.add("botSendMessage", (
cli: MatrixClient,
roomId: string,
message: string,
): Chainable<ISendEventResponse> => {
return cy.wrap(cli.sendMessage(roomId, {
msgtype: "m.text",
body: message,
}), { log: false });
});

View file

@ -1,227 +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.
*/
/// <reference types="cypress" />
import type { FileType, Upload, UploadOpts } from "matrix-js-sdk/src/http-api";
import type { ICreateRoomOpts, ISendEventResponse } from "matrix-js-sdk/src/@types/requests";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { IContent } from "matrix-js-sdk/src/models/event";
import Chainable = Cypress.Chainable;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Returns the MatrixClient from the MatrixClientPeg
*/
getClient(): Chainable<MatrixClient | undefined>;
/**
* Create a room with given options.
* @param options the options to apply when creating the room
* @return the ID of the newly created room
*/
createRoom(options: ICreateRoomOpts): Chainable<string>;
/**
* 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)
*/
createSpace(options: ICreateRoomOpts): Chainable<string>;
/**
* 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
*/
inviteUser(roomId: string, userId: string): Chainable<{}>;
/**
* Sets account data for the user.
* @param type The type of account data.
* @param data The data to store.
*/
setAccountData(type: string, data: object): Chainable<{}>;
/**
* @param {string} roomId
* @param {string} threadId
* @param {string} eventType
* @param {Object} content
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
sendEvent(
roomId: string,
threadId: string | null,
eventType: string,
content: IContent
): Chainable<ISendEventResponse>;
/**
* @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.
*/
setDisplayName(name: string): Chainable<{}>;
/**
* @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.
*/
setAvatarUrl(url: string): Chainable<{}>;
/**
* 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 a Buffer, String or ReadStream.
*/
uploadContent(
file: FileType,
opts?: UploadOpts,
): Chainable<Awaited<Upload["promise"]>>;
/**
* Turn an MXC URL into an HTTP one. <strong>This method is experimental and
* may change.</strong>
* @param {string} mxcUrl The MXC URL
* @param {Number} width The desired width of the thumbnail.
* @param {Number} height The desired height of the thumbnail.
* @param {string} resizeMethod The thumbnail resize method to use, either
* "crop" or "scale".
* @param {Boolean} allowDirectLinks If true, return any non-mxc URLs
* directly. Fetching such URLs will leak information about the user to
* anyone they share a room with. If false, will return null for such URLs.
* @return {?string} the avatar URL or null.
*/
mxcUrlToHttp(
mxcUrl: string,
width?: number,
height?: number,
resizeMethod?: string,
allowDirectLinks?: boolean,
): string | null;
/**
* Gets the list of DMs with a given user
* @param userId The ID of the user
* @return the list of DMs with that user
*/
getDmRooms(userId: string): Chainable<string[]>;
/**
* Boostraps cross-signing.
*/
bootstrapCrossSigning(): Chainable<void>;
/**
* Joins the given room by alias or ID
* @param roomIdOrAlias the id or alias of the room to join
*/
joinRoom(roomIdOrAlias: string): Chainable<Room>;
}
}
}
Cypress.Commands.add("getClient", (): Chainable<MatrixClient | undefined> => {
return cy.window({ log: false }).then(win => win.mxMatrixClientPeg.matrixClient);
});
Cypress.Commands.add("getDmRooms", (userId: string): Chainable<string[]> => {
return cy.getClient()
.then(cli => cli.getAccountData("m.direct")?.getContent<Record<string, string[]>>())
.then(dmRoomMap => dmRoomMap[userId] ?? []);
});
Cypress.Commands.add("createRoom", (options: ICreateRoomOpts): Chainable<string> => {
return cy.window({ log: false }).then(async win => {
const cli = win.mxMatrixClientPeg.matrixClient;
const resp = await cli.createRoom(options);
const roomId = resp.room_id;
if (!cli.getRoom(roomId)) {
await new Promise<void>(resolve => {
const onRoom = (room: Room) => {
if (room.roomId === roomId) {
cli.off(win.matrixcs.ClientEvent.Room, onRoom);
resolve();
}
};
cli.on(win.matrixcs.ClientEvent.Room, onRoom);
});
}
return roomId;
});
});
Cypress.Commands.add("createSpace", (options: ICreateRoomOpts): Chainable<string> => {
return cy.createRoom({
...options,
creation_content: {
"type": "m.space",
},
});
});
Cypress.Commands.add("inviteUser", (roomId: string, userId: string): Chainable<{}> => {
return cy.getClient().then(async (cli: MatrixClient) => {
return cli.invite(roomId, userId);
});
});
Cypress.Commands.add("setAccountData", (type: string, data: object): Chainable<{}> => {
return cy.getClient().then(async (cli: MatrixClient) => {
return cli.setAccountData(type, data);
});
});
Cypress.Commands.add("sendEvent", (
roomId: string,
threadId: string | null,
eventType: string,
content: IContent,
): Chainable<ISendEventResponse> => {
return cy.getClient().then(async (cli: MatrixClient) => {
return cli.sendEvent(roomId, threadId, eventType, content);
});
});
Cypress.Commands.add("setDisplayName", (name: string): Chainable<{}> => {
return cy.getClient().then(async (cli: MatrixClient) => {
return cli.setDisplayName(name);
});
});
Cypress.Commands.add("uploadContent", (file: FileType, opts?: UploadOpts): Chainable<Awaited<Upload["promise"]>> => {
return cy.getClient().then(async (cli: MatrixClient) => {
return cli.uploadContent(file, opts);
});
});
Cypress.Commands.add("setAvatarUrl", (url: string): Chainable<{}> => {
return cy.getClient().then(async (cli: MatrixClient) => {
return cli.setAvatarUrl(url);
});
});
Cypress.Commands.add("bootstrapCrossSigning", () => {
cy.window({ log: false }).then(win => {
win.mxMatrixClientPeg.matrixClient.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async func => { await func({}); },
});
});
});
Cypress.Commands.add("joinRoom", (roomIdOrAlias: string): Chainable<Room> => {
return cy.getClient().then(cli => cli.joinRoom(roomIdOrAlias));
});

View file

@ -1,57 +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.
*/
/// <reference types="cypress" />
import Chainable = Cypress.Chainable;
// Mock the clipboard, as only Electron gives the app permission to the clipboard API by default
// Virtual clipboard
let copyText: string;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Mock the clipboard on the current window, ready for calling `getClipboardText`.
* Irreversible, refresh the window to restore mock.
*/
mockClipboard(): Chainable<AUTWindow>;
/**
* Read text from the mocked clipboard.
* @return {string} the clipboard text
*/
getClipboardText(): Chainable<string>;
}
}
}
Cypress.Commands.add("mockClipboard", () => {
cy.window({ log: false }).then(win => {
win.navigator.clipboard.writeText = (text) => {
copyText = text;
return Promise.resolve();
};
});
});
Cypress.Commands.add("getClipboardText", (): Chainable<string> => {
return cy.wrap(copyText);
});
// Needed to make this file a module
export { };

View file

@ -1,48 +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.
*/
/// <reference types="cypress" />
import Chainable = Cypress.Chainable;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
// Get the composer element
// selects main timeline composer by default
// set `isRightPanel` true to select right panel composer
getComposer(isRightPanel?: boolean): Chainable<JQuery>;
// Open the message composer kebab menu
openMessageComposerOptions(isRightPanel?: boolean): Chainable<JQuery>;
}
}
}
Cypress.Commands.add("getComposer", (isRightPanel?: boolean): Chainable<JQuery> => {
const panelClass = isRightPanel ? '.mx_RightPanel' : '.mx_RoomView_body';
return cy.get(`${panelClass} .mx_MessageComposer`);
});
Cypress.Commands.add("openMessageComposerOptions", (isRightPanel?: boolean): Chainable<JQuery> => {
cy.getComposer(isRightPanel).within(() => {
cy.get('[aria-label="More options"]').click();
});
return cy.get('.mx_MessageComposer_Menu');
});
// Needed to make this file a module
export { };

View file

@ -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.
*/
/// <reference types="cypress" />
import Chainable = Cypress.Chainable;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Gets you into the `body` of the selectable iframe. Best to call
* `within({}, () => { ... })` on the returned Chainable to access
* further elements.
* @param selector The jquery selector to find the frame with.
*/
accessIframe(selector: string): Chainable<JQuery<HTMLElement>>;
}
}
}
// Inspired by https://www.cypress.io/blog/2020/02/12/working-with-iframes-in-cypress/
Cypress.Commands.add("accessIframe", (selector: string): Chainable<JQuery<HTMLElement>> => {
return cy.get(selector)
.its("0.contentDocument.body").should("not.be.empty")
// Cypress loses types in the mess of wrapping, so force cast
.then(cy.wrap) as Chainable<JQuery<HTMLElement>>;
});
// Needed to make this file a module
export { };

View file

@ -1,42 +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;
/// <reference types="cypress" />
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Enables a labs feature for an element session.
* Has to be called before the session is initialized
* @param feature labsFeature to enable (e.g. "feature_spotlight")
*/
enableLabsFeature(feature: string): Chainable<null>;
}
}
}
Cypress.Commands.add("enableLabsFeature", (feature: string): Chainable<null> => {
return cy.window({ log: false }).then(win => {
win.localStorage.setItem(`mx_labs_feature_${feature}`, "true");
}).then(() => null);
});
// Needed to make this file a module
export { };

View file

@ -1,134 +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.
*/
/// <reference types="cypress" />
import Chainable = Cypress.Chainable;
import { SynapseInstance } from "../plugins/synapsedocker";
export interface UserCredentials {
accessToken: string;
username: string;
userId: string;
deviceId: string;
password: string;
homeServer: string;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Generates a test user and instantiates an Element session with that user.
* @param synapse the synapse returned by startSynapse
* @param displayName the displayName to give the test user
* @param prelaunchFn optional function to run before the app is visited
*/
initTestUser(
synapse: SynapseInstance,
displayName: string,
prelaunchFn?: () => void,
): Chainable<UserCredentials>;
/**
* Logs into synapse with the given username/password
* @param synapse the synapse returned by startSynapse
* @param username login username
* @param password login password
*/
loginUser(
synapse: SynapseInstance,
username: string,
password: string,
): Chainable<UserCredentials>;
}
}
}
// eslint-disable-next-line max-len
Cypress.Commands.add("loginUser", (synapse: SynapseInstance, username: string, password: string): Chainable<UserCredentials> => {
const url = `${synapse.baseUrl}/_matrix/client/r0/login`;
return cy.request<{
access_token: string;
user_id: string;
device_id: string;
home_server: string;
}>({
url,
method: "POST",
body: {
"type": "m.login.password",
"identifier": {
"type": "m.id.user",
"user": username,
},
"password": password,
},
}).then(response => ({
password,
username,
accessToken: response.body.access_token,
userId: response.body.user_id,
deviceId: response.body.device_id,
homeServer: response.body.home_server,
}));
});
// eslint-disable-next-line max-len
Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string, prelaunchFn?: () => void): Chainable<UserCredentials> => {
// XXX: work around Cypress not clearing IDB between tests
cy.window({ log: false }).then(win => {
win.indexedDB.databases()?.then(databases => {
databases.forEach(database => {
win.indexedDB.deleteDatabase(database.name);
});
});
});
const username = Cypress._.uniqueId("userId_");
const password = Cypress._.uniqueId("password_");
return cy.registerUser(synapse, username, password, displayName).then(() => {
return cy.loginUser(synapse, username, password);
}).then(response => {
cy.window({ log: false }).then(win => {
// Seed the localStorage with the required credentials
win.localStorage.setItem("mx_hs_url", synapse.baseUrl);
win.localStorage.setItem("mx_user_id", response.userId);
win.localStorage.setItem("mx_access_token", response.accessToken);
win.localStorage.setItem("mx_device_id", response.deviceId);
win.localStorage.setItem("mx_is_guest", "false");
win.localStorage.setItem("mx_has_pickle_key", "false");
win.localStorage.setItem("mx_has_access_token", "true");
// Ensure the language is set to a consistent value
win.localStorage.setItem("mx_local_settings", '{"language":"en"}');
});
prelaunchFn?.();
return cy.visit("/").then(() => {
// wait for the app to load
return cy.get(".mx_MatrixChat", { timeout: 30000 });
}).then(() => ({
password,
username,
accessToken: response.accessToken,
userId: response.userId,
deviceId: response.deviceId,
homeServer: response.homeServer,
}));
});
});

View file

@ -1,62 +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.
*/
/// <reference types="cypress" />
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
// Intercept all /_matrix/ networking requests for the logged in user and fail them
goOffline(): void;
// Remove intercept on all /_matrix/ networking requests
goOnline(): void;
}
}
}
// We manage intercepting Matrix APIs here, as fully disabling networking will disconnect
// the browser under test from the Cypress runner, so can cause issues.
Cypress.Commands.add("goOffline", (): void => {
cy.log("Going offline");
cy.window({ log: false }).then(win => {
cy.intercept("**/_matrix/**", {
headers: {
"Authorization": "Bearer " + win.mxMatrixClientPeg.matrixClient.getAccessToken(),
},
}, req => {
req.destroy();
});
});
});
Cypress.Commands.add("goOnline", (): void => {
cy.log("Going online");
cy.window({ log: false }).then(win => {
cy.intercept("**/_matrix/**", {
headers: {
"Authorization": "Bearer " + win.mxMatrixClientPeg.matrixClient.getAccessToken(),
},
}, req => {
req.continue();
});
win.dispatchEvent(new Event("online"));
});
});
// Needed to make this file a module
export { };

View file

@ -1,54 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import { SnapshotOptions as PercySnapshotOptions } from '@percy/core';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface SnapshotOptions extends PercySnapshotOptions {
domTransformation?: (documentClone: Document) => void;
}
interface Chainable {
percySnapshotElement(name?: string, options?: SnapshotOptions);
}
interface Chainable {
/**
* Takes a Percy snapshot of a given element
*/
percySnapshotElement(name: string, options: SnapshotOptions): Chainable<void>;
}
}
}
Cypress.Commands.add("percySnapshotElement", { prevSubject: "element" }, (subject, name, options) => {
cy.percySnapshot(name, {
domTransformation: documentClone => scope(documentClone, subject.selector),
...options,
});
});
function scope(documentClone: Document, selector: string): Document {
const element = documentClone.querySelector(selector);
documentClone.querySelector('body').innerHTML = element.outerHTML;
return documentClone;
}
export { };

View file

@ -1,58 +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.
*/
/// <reference types="cypress" />
import Chainable = Cypress.Chainable;
import AUTWindow = Cypress.AUTWindow;
import { ProxyInstance } from '../plugins/sliding-sync';
import { SynapseInstance } from "../plugins/synapsedocker";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Start a sliding sync proxy instance.
* @param synapse the synapse instance returned by startSynapse
*/
startProxy(synapse: SynapseInstance): Chainable<ProxyInstance>;
/**
* Custom command wrapping task:proxyStop whilst preventing uncaught exceptions
* for if Docker stopping races with the app's background sync loop.
* @param proxy the proxy instance returned by startProxy
*/
stopProxy(proxy: ProxyInstance): Chainable<AUTWindow>;
}
}
}
function startProxy(synapse: SynapseInstance): Chainable<ProxyInstance> {
return cy.task<ProxyInstance>("proxyStart", synapse);
}
function stopProxy(proxy?: ProxyInstance): Chainable<AUTWindow> {
if (!proxy) return;
// Navigate away from app to stop the background network requests which will race with Synapse shutting down
return cy.window({ log: false }).then((win) => {
win.location.href = 'about:blank';
cy.task("proxyStop", proxy);
});
}
Cypress.Commands.add("startProxy", startProxy);
Cypress.Commands.add("stopProxy", stopProxy);

View file

@ -1,177 +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.
*/
/// <reference types="cypress" />
import Chainable = Cypress.Chainable;
import type { SettingLevel } from "../../src/settings/SettingLevel";
import type SettingsStore from "../../src/settings/SettingsStore";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Returns the SettingsStore
*/
getSettingsStore(): Chainable<typeof SettingsStore | undefined>; // XXX: Importing SettingsStore causes a bunch of type lint errors
/**
* Open the top left user menu, returning a handle to the resulting context menu.
*/
openUserMenu(): Chainable<JQuery<HTMLElement>>;
/**
* Open user settings (via user menu), returning a handle to the resulting dialog.
* @param tab the name of the tab to switch to after opening, optional.
*/
openUserSettings(tab?: string): Chainable<JQuery<HTMLElement>>;
/**
* Open room settings (via room header menu), returning a handle to the resulting dialog.
* @param tab the name of the tab to switch to after opening, optional.
*/
openRoomSettings(tab?: string): Chainable<JQuery<HTMLElement>>;
/**
* Switch settings tab to the one by the given name, ideally call this in the context of the dialog.
* @param tab the name of the tab to switch to.
*/
switchTab(tab: string): Chainable<JQuery<HTMLElement>>;
/**
* Close dialog, ideally call this in the context of the dialog.
*/
closeDialog(): Chainable<JQuery<HTMLElement>>;
/**
* Join the given beta, the `Labs` tab must already be opened,
* ideally call this in the context of the dialog.
* @param name the name of the beta to join.
*/
joinBeta(name: string): Chainable<JQuery<HTMLElement>>;
/**
* Leave the given beta, the `Labs` tab must already be opened,
* ideally call this in the context of the dialog.
* @param name the name of the beta to leave.
*/
leaveBeta(name: string): Chainable<JQuery<HTMLElement>>;
/**
* 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.
*/
setSettingValue(settingName: string, roomId: string, level: SettingLevel, value: any): Chainable<void>;
/**
* Gets the value of a setting. The room ID is optional if the
* setting is not to be applied to any particular room, otherwise it
* should be supplied.
* @param {string} settingName The name of the setting to read the
* value of.
* @param {String} roomId The room ID to read the setting value in,
* may be null.
* @param {boolean} excludeDefault True to disable using the default
* value.
* @return {*} The value, or null if not found
*/
getSettingValue<T>(settingName: string, roomId?: string, excludeDefault?: boolean): Chainable<T>;
}
}
}
Cypress.Commands.add("getSettingsStore", (): Chainable<typeof SettingsStore> => {
return cy.window({ log: false }).then(win => win.mxSettingsStore);
});
Cypress.Commands.add("setSettingValue", (
name: string,
roomId: string,
level: SettingLevel,
value: any,
): Chainable<void> => {
return cy.getSettingsStore().then((store: typeof SettingsStore) => {
return cy.wrap(store.setValue(name, roomId, level, value));
});
});
// eslint-disable-next-line max-len
Cypress.Commands.add("getSettingValue", <T = any>(name: string, roomId?: string, excludeDefault?: boolean): Chainable<T> => {
return cy.getSettingsStore().then((store: typeof SettingsStore) => {
return store.getValue(name, roomId, excludeDefault);
});
});
Cypress.Commands.add("openUserMenu", (): Chainable<JQuery<HTMLElement>> => {
cy.get('[aria-label="User menu"]').click();
return cy.get(".mx_ContextualMenu");
});
Cypress.Commands.add("openUserSettings", (tab?: string): Chainable<JQuery<HTMLElement>> => {
cy.openUserMenu().within(() => {
cy.get('[aria-label="All settings"]').click();
});
return cy.get(".mx_UserSettingsDialog").within(() => {
if (tab) {
cy.switchTab(tab);
}
});
});
Cypress.Commands.add("openRoomSettings", (tab?: string): Chainable<JQuery<HTMLElement>> => {
cy.get(".mx_RoomHeader_name").click();
cy.get(".mx_RoomTile_contextMenu").within(() => {
cy.get('[aria-label="Settings"]').click();
});
return cy.get(".mx_RoomSettingsDialog").within(() => {
if (tab) {
cy.switchTab(tab);
}
});
});
Cypress.Commands.add("switchTab", (tab: string): Chainable<JQuery<HTMLElement>> => {
return cy.get(".mx_TabbedView_tabLabels").within(() => {
cy.contains(".mx_TabbedView_tabLabel", tab).click();
});
});
Cypress.Commands.add("closeDialog", (): Chainable<JQuery<HTMLElement>> => {
return cy.get('[aria-label="Close dialog"]').click();
});
Cypress.Commands.add("joinBeta", (name: string): Chainable<JQuery<HTMLElement>> => {
return cy.contains(".mx_BetaCard_title", name).closest(".mx_BetaCard").within(() => {
return cy.get(".mx_BetaCard_buttons").contains("Join the beta").click();
});
});
Cypress.Commands.add("leaveBeta", (name: string): Chainable<JQuery<HTMLElement>> => {
return cy.contains(".mx_BetaCard_title", name).closest(".mx_BetaCard").within(() => {
return cy.get(".mx_BetaCard_buttons").contains("Leave the beta").click();
});
});
// Needed to make this file a module
export { };

View file

@ -1,122 +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.
*/
/// <reference types="cypress" />
import * as crypto from 'crypto';
import Chainable = Cypress.Chainable;
import AUTWindow = Cypress.AUTWindow;
import { SynapseInstance } from "../plugins/synapsedocker";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Start a synapse instance with a given config template.
* @param template path to template within cypress/plugins/synapsedocker/template/ directory.
*/
startSynapse(template: string): Chainable<SynapseInstance>;
/**
* Custom command wrapping task:synapseStop whilst preventing uncaught exceptions
* for if Synapse stopping races with the app's background sync loop.
* @param synapse the synapse instance returned by startSynapse
*/
stopSynapse(synapse: SynapseInstance): Chainable<AUTWindow>;
/**
* Register a user on the given Synapse using the shared registration secret.
* @param synapse the synapse instance returned by startSynapse
* @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(
synapse: SynapseInstance,
username: string,
password: string,
displayName?: string,
): Chainable<Credentials>;
}
}
}
function startSynapse(template: string): Chainable<SynapseInstance> {
return cy.task<SynapseInstance>("synapseStart", template);
}
function stopSynapse(synapse?: SynapseInstance): Chainable<AUTWindow> {
if (!synapse) return;
// Navigate away from app to stop the background network requests which will race with Synapse shutting down
return cy.window({ log: false }).then((win) => {
win.location.href = 'about:blank';
cy.task("synapseStop", synapse.synapseId);
});
}
interface Credentials {
accessToken: string;
userId: string;
deviceId: string;
homeServer: string;
}
function registerUser(
synapse: SynapseInstance,
username: string,
password: string,
displayName?: string,
): Chainable<Credentials> {
const url = `${synapse.baseUrl}/_synapse/admin/v1/register`;
return cy.then(() => {
// get a nonce
return cy.request<{ nonce: string }>({ url });
}).then(response => {
const { nonce } = response.body;
const mac = crypto.createHmac('sha1', synapse.registrationSecret).update(
`${nonce}\0${username}\0${password}\0notadmin`,
).digest('hex');
return cy.request<{
access_token: string;
user_id: string;
home_server: string;
device_id: string;
}>({
url,
method: "POST",
body: {
nonce,
username,
password,
mac,
admin: false,
displayname: displayName,
},
});
}).then(response => ({
homeServer: response.body.home_server,
accessToken: response.body.access_token,
userId: response.body.user_id,
deviceId: response.body.device_id,
}));
}
Cypress.Commands.add("startSynapse", startSynapse);
Cypress.Commands.add("stopSynapse", stopSynapse);
Cypress.Commands.add("registerUser", registerUser);

View file

@ -1,68 +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.
*/
/// <reference types="cypress" />
import Chainable = Cypress.Chainable;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
// Scroll to the top of the timeline
scrollToTop(): void;
// Find the event tile matching the given sender & body
findEventTile(sender: string, body: string): Chainable<JQuery>;
}
}
}
export interface Message {
sender: string;
body: string;
encrypted: boolean;
continuation: boolean;
}
Cypress.Commands.add("scrollToTop", (): void => {
cy.get(".mx_RoomView_timeline .mx_ScrollPanel").scrollTo("top", { duration: 100 }).then(ref => {
if (ref.scrollTop() > 0) {
return cy.scrollToTop();
}
});
});
Cypress.Commands.add("findEventTile", (sender: string, body: string): Chainable<JQuery> => {
// We can't just use a bunch of `.contains` here due to continuations meaning that the events don't
// have their own rendered sender displayname so we have to walk the list to keep track of the sender.
return cy.get(".mx_RoomView_MessageList .mx_EventTile").then(refs => {
let latestSender: string;
for (let i = 0; i < refs.length; i++) {
const ref = refs.eq(i);
const displayName = ref.find(".mx_DisambiguatedProfile_displayName");
if (displayName) {
latestSender = displayName.text();
}
if (latestSender === sender && ref.find(".mx_EventTile_body").text() === body) {
return ref;
}
}
});
});
// Needed to make this file a module
export { };

View file

@ -1,82 +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.
*/
/// <reference types="cypress" />
// @see https://github.com/cypress-io/cypress/issues/915#issuecomment-475862672
// Modified due to changes to `cy.queue` https://github.com/cypress-io/cypress/pull/17448
// Note: this DOES NOT run Promises in parallel like `Promise.all` due to the nature
// of Cypress promise-like objects and command queue. This only makes it convenient to use the same
// API but runs the commands sequentially.
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
type ChainableValue<T> = T extends Cypress.Chainable<infer V> ? V : T;
interface cy {
all<T extends Cypress.Chainable[] | []>(
commands: T
): Cypress.Chainable<{ [P in keyof T]: ChainableValue<T[P]> }>;
queue: any;
}
interface Chainable {
chainerId: string;
}
}
}
const chainStart = Symbol("chainStart");
/**
* @description Returns a single Chainable that resolves when all of the Chainables pass.
* @param {Cypress.Chainable[]} commands - List of Cypress.Chainable to resolve.
* @returns {Cypress.Chainable} Cypress when all Chainables are resolved.
*/
cy.all = function all(commands): Cypress.Chainable {
const chain = cy.wrap(null, { log: false });
const stopCommand = Cypress._.find(cy.queue.get(), {
attributes: { chainerId: chain.chainerId },
});
const startCommand = Cypress._.find(cy.queue.get(), {
attributes: { chainerId: commands[0].chainerId },
});
const p = chain.then(() => {
return cy.wrap(
// @see https://lodash.com/docs/4.17.15#lodash
Cypress._(commands)
.map(cmd => {
return cmd[chainStart]
? cmd[chainStart].attributes
: Cypress._.find(cy.queue.get(), {
attributes: { chainerId: cmd.chainerId },
}).attributes;
})
.concat(stopCommand.attributes)
.slice(1)
.map(cmd => {
return cmd.prev.get("subject");
})
.value(),
);
});
p[chainStart] = startCommand;
return p;
};
// Needed to make this file a module
export { };

View file

@ -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.
*/
/// <reference types="cypress" />
import Chainable = Cypress.Chainable;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Opens the given room by name. The room must be visible in the
* room list.
* @param name The room name to find and click on/open.
*/
viewRoomByName(name: string): Chainable<JQuery<HTMLElement>>;
/**
* 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
*/
getSpacePanelButton(name: string): Chainable<JQuery<HTMLElement>>;
/**
* 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.
*/
viewSpaceHomeByName(name: string): Chainable<JQuery<HTMLElement>>;
/**
* 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.
*/
viewSpaceByName(name: string): Chainable<JQuery<HTMLElement>>;
}
}
}
Cypress.Commands.add("viewRoomByName", (name: string): Chainable<JQuery<HTMLElement>> => {
return cy.get(`.mx_RoomTile[aria-label="${name}"]`).click();
});
Cypress.Commands.add("getSpacePanelButton", (name: string): Chainable<JQuery<HTMLElement>> => {
return cy.get(`.mx_SpaceButton[aria-label="${name}"]`);
});
Cypress.Commands.add("viewSpaceByName", (name: string): Chainable<JQuery<HTMLElement>> => {
return cy.getSpacePanelButton(name).click();
});
Cypress.Commands.add("viewSpaceHomeByName", (name: string): Chainable<JQuery<HTMLElement>> => {
return cy.getSpacePanelButton(name).dblclick();
});
// Needed to make this file a module
export { };

View file

@ -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.
*/
/// <reference types="cypress" />
import Chainable = Cypress.Chainable;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Starts a web server which serves the given HTML.
* @param html The HTML to serve
* @returns The URL at which the HTML can be accessed.
*/
serveHtmlFile(html: string): Chainable<string>;
/**
* Stops all running web servers.
*/
stopWebServers(): Chainable<void>;
}
}
}
function serveHtmlFile(html: string): Chainable<string> {
return cy.task<string>("serveHtmlFile", html);
}
function stopWebServers(): Chainable<void> {
return cy.task("stopWebServers");
}
Cypress.Commands.add("serveHtmlFile", serveHtmlFile);
Cypress.Commands.add("stopWebServers", stopWebServers);
// Needed to make this file a module
export { };

View file

@ -1,23 +0,0 @@
{
"compilerOptions": {
"target": "es2016",
"jsx": "react",
"lib": [
"es2020",
"dom",
"dom.iterable"
],
"types": [
"cypress",
"cypress-axe",
"@percy/cypress"
],
"resolveJsonModule": true,
"esModuleInterop": true,
"moduleResolution": "node",
"module": "commonjs"
},
"include": [
"**/*.ts"
]
}

23
docs/SUMMARY.md Normal file
View file

@ -0,0 +1,23 @@
# Summary
- [Introduction](../README.md)
# Customisation
- [Skinning](skinning.md)
# Deep dive
- [Cider editor](ciderEditor.md)
- [Iconography](icons.md)
- [Jitsi](jitsi.md)
- [Local echo](local-echo-dev.md)
- [Media](media-handling.md)
- [Room List Store](room-list-store.md)
- [Scrolling](scrolling.md)
- [Usercontent](usercontent.md)
- [Widget layouts](widget-layouts.md)
# Testing
- [Playwright end to end](playwright.md)

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