Remove Cypress & Playwright in their entirety (#12145)

This commit is contained in:
Michael Telatynski 2024-01-16 09:48:49 +00:00 committed by GitHub
parent 0b6d2f923d
commit 5983528a8d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 73 additions and 5812 deletions

View file

@ -169,7 +169,7 @@ module.exports = {
}, },
overrides: [ overrides: [
{ {
files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}", "cypress/**/*.ts", "playwright/**/*.ts"], files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}", "playwright/**/*.ts"],
extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react"], extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react"],
rules: { rules: {
"@typescript-eslint/explicit-function-return-type": [ "@typescript-eslint/explicit-function-return-type": [
@ -233,14 +233,14 @@ module.exports = {
}, },
}, },
{ {
files: ["test/**/*.{ts,tsx}", "cypress/**/*.ts", "playwright/**/*.ts"], files: ["test/**/*.{ts,tsx}", "playwright/**/*.ts"],
extends: ["plugin:matrix-org/jest"], extends: ["plugin:matrix-org/jest"],
rules: { rules: {
// We don't need super strict typing in test utilities // We don't need super strict typing in test utilities
"@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-member-accessibility": "off", "@typescript-eslint/explicit-member-accessibility": "off",
// Jest/Cypress specific // Jest/Playwright specific
// Disabled tests are a reality for now but as soon as all of the xits are // Disabled tests are a reality for now but as soon as all of the xits are
// eliminated, we should enforce this. // eliminated, we should enforce this.
@ -255,29 +255,11 @@ module.exports = {
], ],
}, },
}, },
{
files: ["cypress/**/*.ts"],
parserOptions: {
project: ["./cypress/tsconfig.json"],
},
rules: {
// Cypress "promises" work differently - disable some related rules
"jest/valid-expect": "off",
"jest/valid-expect-in-promise": "off",
"jest/no-done-callback": "off",
},
},
{ {
files: ["playwright/**/*.ts"], files: ["playwright/**/*.ts"],
parserOptions: { parserOptions: {
project: ["./playwright/tsconfig.json"], project: ["./playwright/tsconfig.json"],
}, },
rules: {
// Cypress "promises" work differently - disable some related rules
"jest/valid-expect": "off",
"jest/valid-expect-in-promise": "off",
"jest/no-done-callback": "off",
},
}, },
], ],
settings: { settings: {

View file

@ -1,231 +0,0 @@
# Triggers after the layered build has finished, taking the artifact and running cypress on it
#
# Also called by a workflow in matrix-js-sdk.
#
name: Cypress End to End Tests
on:
workflow_run:
workflows: ["Element Web - Build"]
types:
- completed
# support calls from other workflows
workflow_call:
inputs:
react-sdk-repository:
type: string
required: true
description: "The name of the github repository to check out and build."
secrets:
KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_RUST:
required: true
KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_LEGACY:
required: true
TCMS_USERNAME:
required: true
TCMS_PASSWORD:
required: true
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
jobs:
prepare:
name: Prepare
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
issues: read
statuses: write
pull-requests: read
outputs:
uuid: ${{ steps.uuid.outputs.value }}
pr_id: ${{ steps.prdetails.outputs.pr_id }}
percy_enable: ${{ steps.percy.outputs.value || '0' }}
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before Cypress is done.
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
context: ${{ github.workflow }} / cypress
sha: ${{ github.event.workflow_run.head_sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- id: prdetails
if: github.event.workflow_run.event == 'pull_request' || github.event.workflow_run.event == 'merge_group'
uses: matrix-org/pr-details-action@v1.3
with:
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
# Percy is disabled while we're figuring out https://github.com/vector-im/wat-internal/issues/36
# and https://github.com/vector-im/wat-internal/issues/56. We're hoping to turn it back on or switch
# to an alternative in the future.
# # Only run Percy when it is demanded or we are running the daily build
# - name: Enable Percy
# id: percy
# if: |
# github.event.workflow_run.event == 'schedule' ||
# (
# github.event.workflow_run.event == 'merge_group' &&
# contains(fromJSON(steps.prdetails.outputs.data).labels.*.name, 'X-Needs-Percy')
# )
# run: echo "value=1" >> $GITHUB_OUTPUT
- name: Generate unique ID 💎
id: uuid
run: echo "value=sha-$GITHUB_SHA-time-$(date +"%s")" >> $GITHUB_OUTPUT
tests:
name: "Run Tests (${{ matrix.crypto }} crypto)"
needs: prepare
runs-on: ubuntu-latest
permissions:
actions: read
issues: read
pull-requests: read
environment: Cypress
strategy:
fail-fast: false
matrix:
# Run tests using both crypto stacks
crypto: [legacy, rust]
ci_node_total: [2]
ci_node_index: [0, 1]
steps:
# The version of chrome shipped by default may not be consistent across runners
# so we explicitly use a specific version of chrome here.
- uses: browser-actions/setup-chrome@803ef6dfb4fdf22089c9563225d95e4a515820a0 # v1
- run: echo "BROWSER_PATH=$(which chrome)" >> $GITHUB_ENV
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: 📥 Download artifact
uses: dawidd6/action-download-artifact@f29d1b6a8930683e80acedfbe6baa2930cd646b4 # v2
with:
run_id: ${{ github.event.workflow_run.id }}
name: previewbuild
path: webapp
# The workflow_run.head_sha is the sha of the head commit but the element-web was built using a simulated
# merge commit - https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
# so use the sha from the tarball for the checkout of the cypress tests
# to make sure we get a matching set of code and tests.
- name: Grab sha from webapp
id: sha
run: |
echo "sha=$(cat webapp/sha)" >> $GITHUB_OUTPUT
- uses: actions/checkout@v4
with:
# XXX: We're checking out untrusted code in a secure context
# We need to be careful to not trust anything this code outputs/may do
#
# Note that (in the absence of a `react-sdk-repository` input),
# we check out from the default repository, which is (for this workflow) the
# *target* repository for the pull request.
#
ref: ${{ steps.sha.outputs.sha }}
persist-credentials: false
path: matrix-react-sdk
repository: ${{ inputs.react-sdk-repository || github.repository }}
# Enable rust crypto if the calling workflow requests it
- name: Enable rust crypto
if: matrix.crypto == 'rust'
run: |
echo "CYPRESS_RUST_CRYPTO=1" >> "$GITHUB_ENV"
- name: Run Cypress tests via knapsack pro
uses: cypress-io/github-action@ebe8b24c4428922d0f793a5c4c96853a633180e3 # v6.6.0
with:
working-directory: matrix-react-sdk
headed: true
start: npx serve -p 8080 -L ../webapp
wait-on: "http://localhost:8080"
record: false
parallel: false
# The built-in Electron runner seems to grind to a halt trying to run the tests, so use chrome.
command: yarn percy exec --parallel -- npx knapsack-pro-cypress --config trashAssetsBeforeRuns=false --browser ${{ env.BROWSER_PATH }}
env:
# Knapsack token and config
KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS: ${{ matrix.crypto == 'rust' && secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_RUST || secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_LEGACY }}
KNAPSACK_PRO_CI_NODE_TOTAL: ${{ matrix.ci_node_total }}
KNAPSACK_PRO_CI_NODE_INDEX: ${{ matrix.ci_node_index }}
KNAPSACK_PRO_TEST_FILE_PATTERN: cypress/e2e/**/*.spec.ts
KNAPSACK_PRO_BRANCH: ${{ github.event.workflow_run.head_branch }}
KNAPSACK_PRO_COMMIT_HASH: ${{ github.event.workflow_run.head_sha }}
# Use existing chromium rather than downloading another
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
# pass GitHub token to allow accurately detecting a build vs a re-run build
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# make Node's os.tmpdir() return something where we actually have permissions
TMPDIR: ${{ runner.temp }}
# pass the Percy token as an environment variable
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
# only run percy on legacy crypto (for now)
PERCY_ENABLE: ${{ matrix.crypto == 'legacy' && needs.prepare.outputs.percy_enable || 0 }}
PERCY_BROWSER_EXECUTABLE: ${{ steps.setup-chrome.outputs.chrome-path }}
# tell Percy more details about the context of this run
PERCY_BRANCH: ${{ github.event.workflow_run.head_branch }}
PERCY_COMMIT: ${{ github.event.workflow_run.head_sha }}
PERCY_PULL_REQUEST: ${{ needs.prepare.outputs.pr_id }}
PERCY_PARALLEL_NONCE: ${{ needs.prepare.outputs.uuid }}
# We manually finalize the build in the report stage
PERCY_PARALLEL_TOTAL: -1
- name: 📤 Upload results artifact
if: failure()
uses: actions/upload-artifact@v3
with:
name: cypress-results-${{ matrix.crypto }}-crypto
path: |
matrix-react-sdk/cypress/screenshots
matrix-react-sdk/cypress/videos
matrix-react-sdk/cypress/synapselogs
matrix-react-sdk/cypress/results/cypresslogs
report:
name: Finalize results
needs:
- prepare
- tests
runs-on: ubuntu-latest
if: always()
permissions:
statuses: write
steps:
- name: Finalize Percy
if: needs.prepare.outputs.percy_enable == '1'
run: npx -p @percy/cli percy build:finalize
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
PERCY_PARALLEL_NONCE: ${{ needs.prepare.outputs.uuid }}
- name: Skip Percy required check
if: needs.prepare.outputs.percy_enable != '1'
uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
description: Percy skipped
context: percy/matrix-react-sdk
sha: ${{ github.event.workflow_run.head_sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: ${{ needs.tests.result == 'success' && 'success' || 'failure' }}
context: ${{ github.workflow }} / cypress
sha: ${{ github.event.workflow_run.head_sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}

View file

@ -3,12 +3,6 @@
# as an artifact and run integration tests. # as an artifact and run integration tests.
name: Element Web - Build name: Element Web - Build
on: on:
# We only need the nightly run for Percy which is disabled while we're
# figuring out https://github.com/vector-im/wat-internal/issues/36 and
# https://github.com/vector-im/wat-internal/issues/56. We're hoping to
# turn it back on or switch to an alternative in the future.
# schedule:
# - cron: "17 4 * * 1-5" # every weekday at 04:17 UTC
pull_request: {} pull_request: {}
merge_group: merge_group:
types: [checks_requested] types: [checks_requested]
@ -82,7 +76,7 @@ jobs:
echo $VERSION > webapp/version echo $VERSION > webapp/version
working-directory: ./element-web working-directory: ./element-web
# Record the react-sdk sha so our cypress tests are from the same sha # Record the react-sdk sha so our Playwright tests are from the same sha
- name: Record react-sdk SHA - name: Record react-sdk SHA
run: | run: |
git rev-parse HEAD > element-web/webapp/sha git rev-parse HEAD > element-web/webapp/sha

11
.gitignore vendored
View file

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

View file

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

View file

@ -2,8 +2,6 @@
![Tests](https://github.com/matrix-org/matrix-react-sdk/actions/workflows/tests.yml/badge.svg) ![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/) [![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) ![Static Analysis](https://github.com/matrix-org/matrix-react-sdk/actions/workflows/static_analysis.yaml/badge.svg)
[![Knapsack Pro Parallel CI builds for Cypress Test - Legacy Crypto](https://img.shields.io/badge/Knapsack%20Pro-Parallel%20%2F%20Cypress%20Test%20--%20Legacy%20Crypto-%230074ff)](https://knapsackpro.com/dashboard/organizations/3882/projects/2469/test_suites/3724/builds?utm_campaign=organization-id-3882&utm_content=test-suite-id-3724&utm_medium=readme&utm_source=knapsack-pro-badge&utm_term=project-id-2469)
[![Knapsack Pro Parallel CI builds for Cypress Test - Rust Crypto](https://img.shields.io/badge/Knapsack%20Pro-Parallel%20%2F%20Cypress%20Test%20--%20Rust%20Crypto-%230074ff)](https://knapsackpro.com/dashboard/organizations/3882/projects/2469/test_suites/3729/builds?utm_campaign=organization-id-3882&utm_content=test-suite-id-3729&utm_medium=readme&utm_source=knapsack-pro-badge&utm_term=project-id-2469)
[![Localazy](https://img.shields.io/endpoint?url=https%3A%2F%2Fconnect.localazy.com%2Fstatus%2Felement-web%2Fdata%3Fcontent%3Dall%26title%3Dlocalazy%26logo%3Dtrue)](https://localazy.com/p/element-web) [![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) [![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) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=matrix-react-sdk&metric=coverage)](https://sonarcloud.io/summary/new_code?id=matrix-react-sdk)
@ -206,5 +204,5 @@ Now the yarn commands should work as normal.
### End-to-End tests ### End-to-End tests
We use Cypress and Element Web for end-to-end tests. See We use Playwright and Element Web for end-to-end tests. See
[`docs/cypress.md`](docs/cypress.md) for more information. [`docs/playwright.md`](docs/playwright.md) for more information.

View file

@ -1,7 +0,0 @@
{
"reporterEnabled": "spec, mocha-junit-reporter",
"mochaJunitReporterReporterOptions": {
"mochaFile": "cypress/results/junit/results-[hash].xml",
"useFullSuiteTitle": true
}
}

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.
*/
import { defineConfig } from "cypress";
import registerPlugins from "./cypress/plugins";
export default defineConfig({
videoUploadOnPasses: false,
projectId: "ppvnzg",
experimentalInteractiveRunEvents: true,
experimentalMemoryManagement: true,
defaultCommandTimeout: 10000,
chromeWebSecurity: false,
e2e: {
setupNodeEvents(on, config) {
return registerPlugins(on, config);
},
baseUrl: "http://localhost:8080",
specPattern: "cypress/e2e/**/*.spec.{js,jsx,ts,tsx}",
},
env: {
// Docker tag to use for `ghcr.io/matrix-org/sliding-sync` image.
SLIDING_SYNC_PROXY_TAG: "v0.99.3",
HOMESERVER: "synapse",
},
retries: {
runMode: 4,
openMode: 0,
},
// disable logging of HTTP requests made to the Cypress server. They are noisy and not very helpful.
// @ts-ignore https://github.com/cypress-io/cypress/issues/26284
morgan: false,
// Create XML result files
reporter: "cypress-multi-reporters",
reporterOptions: {
configFile: "cypress-ci-reporter-config.json",
},
});

View file

@ -1,19 +0,0 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
it("Dummy test to make CI pass", () => {});

View file

@ -1,52 +0,0 @@
/*
Copyright 2023 Mikhail Aheichyk
Copyright 2023 Nordeck IT + Consulting GmbH.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
/**
* Resolves when room state matches predicate.
* @param win window object
* @param matrixClient MatrixClient instance that can be user or bot
* @param roomId room id to find room and check
* @param predicate defines condition that is used to check the room state
*/
export function waitForRoom(
win: Cypress.AUTWindow,
matrixClient: MatrixClient,
roomId: string,
predicate: (room: Room) => boolean,
): Promise<void> {
return new Promise((resolve, reject) => {
const room = matrixClient.getRoom(roomId);
if (predicate(room)) {
resolve();
return;
}
function onEvent(ev: MatrixEvent) {
if (ev.getRoomId() !== roomId) return;
if (predicate(room)) {
matrixClient.removeListener(win.matrixcs.ClientEvent.Event, onEvent);
resolve();
}
}
matrixClient.on(win.matrixcs.ClientEvent.Event, onEvent);
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

62
cypress/global.d.ts vendored
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.
*/
// eslint-disable-next-line no-restricted-imports
import "matrix-js-sdk/src/@types/global";
import type {
MatrixClient,
ClientEvent,
MatrixScheduler,
MemoryCryptoStore,
MemoryStore,
Preset,
RoomStateEvent,
Visibility,
RoomMemberEvent,
ICreateClientOpts,
} from "matrix-js-sdk/src/matrix";
import type { SettingLevel } from "../src/settings/SettingLevel";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface ApplicationWindow {
// XXX: Importing SettingsStore causes a bunch of type lint errors
mxSettingsStore: {
setValue(settingName: string, roomId: string | null, level: SettingLevel, value: any): Promise<void>;
};
mxMatrixClientPeg: {
matrixClient?: MatrixClient;
};
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);
};
}
}
}
export { MatrixClient };

View file

@ -1,207 +0,0 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <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";
import { HomeserverConfig, HomeserverInstance } from "../utils/homeserver";
import { StartHomeserverOpts } from "../../support/homeserver";
// A cypress plugins to add command to start & stop dendrites in
// docker with preset templates.
const dendrites = new Map<string, HomeserverInstance>();
const dockerConfigDir = "/etc/dendrite/";
const dendriteConfigFile = "dendrite.yaml";
function randB64Bytes(numBytes: number): string {
return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, "");
}
async function cfgDirFromTemplate(template: string, dendriteImage: string): Promise<HomeserverConfig> {
template = "default";
const templateDir = path.join(__dirname, "templates", template);
const stats = await fse.stat(templateDir);
if (!stats?.isDirectory) {
throw new Error(`No such template: ${template}`);
}
const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), "react-sdk-dendritedocker-"));
// copy the contents of the template dir, omitting homeserver.yaml as we'll template that
console.log(`Copy ${templateDir} -> ${tempDir}`);
await fse.copy(templateDir, tempDir, { filter: (f) => path.basename(f) !== dendriteConfigFile });
const registrationSecret = randB64Bytes(16);
const port = await getFreePort();
const baseUrl = `http://localhost:${port}`;
// now copy homeserver.yaml, applying substitutions
console.log(`Gen ${path.join(templateDir, dendriteConfigFile)}`);
let hsYaml = await fse.readFile(path.join(templateDir, dendriteConfigFile), "utf8");
hsYaml = hsYaml.replace(/{{REGISTRATION_SECRET}}/g, registrationSecret);
await fse.writeFile(path.join(tempDir, dendriteConfigFile), hsYaml);
await dockerRun({
image: dendriteImage,
params: ["--rm", "--entrypoint=", "-v", `${tempDir}:/mnt`],
containerName: `react-sdk-cypress-dendrite-keygen`,
cmd: ["/usr/bin/generate-keys", "-private-key", "/mnt/matrix_key.pem"],
});
return {
port,
baseUrl,
configDir: tempDir,
registrationSecret,
};
}
// Start a dendrite instance: the template must be the name of
// one of the templates in the cypress/plugins/dendritedocker/templates
// directory
async function dendriteStart(opts: StartHomeserverOpts): Promise<HomeserverInstance> {
return containerStart(opts.template, false);
}
// Start a dendrite instance using pinecone routing: the template must be the name of
// one of the templates in the cypress/plugins/dendritedocker/templates
// directory
async function dendritePineconeStart(template: string): Promise<HomeserverInstance> {
return containerStart(template, true);
}
async function containerStart(template: string, usePinecone: boolean): Promise<HomeserverInstance> {
let dendriteImage = "matrixdotorg/dendrite-monolith:main";
let dendriteEntrypoint = "/usr/bin/dendrite";
if (usePinecone) {
dendriteImage = "matrixdotorg/dendrite-demo-pinecone:main";
dendriteEntrypoint = "/usr/bin/dendrite-demo-pinecone";
}
const denCfg = await cfgDirFromTemplate(template, dendriteImage);
console.log(`Starting dendrite with config dir ${denCfg.configDir}...`);
const dendriteId = await dockerRun({
image: dendriteImage,
params: [
"--rm",
"-v",
`${denCfg.configDir}:` + dockerConfigDir,
"-p",
`${denCfg.port}:8008/tcp`,
"--entrypoint",
dendriteEntrypoint,
],
containerName: `react-sdk-cypress-dendrite`,
cmd: ["--config", dockerConfigDir + dendriteConfigFile, "--really-enable-open-registration", "true", "run"],
});
console.log(`Started dendrite with id ${dendriteId} on port ${denCfg.port}.`);
// Await Dendrite healthcheck
await dockerExec({
containerId: dendriteId,
params: [
"curl",
"--connect-timeout",
"30",
"--retry",
"30",
"--retry-delay",
"1",
"--retry-all-errors",
"--silent",
"http://localhost:8008/_matrix/client/versions",
],
});
const dendrite: HomeserverInstance = { serverId: dendriteId, ...denCfg };
dendrites.set(dendriteId, dendrite);
return dendrite;
}
async function dendriteStop(id: string): Promise<void> {
const denCfg = dendrites.get(id);
if (!denCfg) throw new Error("Unknown dendrite ID");
const dendriteLogsPath = path.join("cypress", "dendritelogs", id);
await fse.ensureDir(dendriteLogsPath);
await dockerLogs({
containerId: id,
stdoutFile: path.join(dendriteLogsPath, "stdout.log"),
stderrFile: path.join(dendriteLogsPath, "stderr.log"),
});
await dockerStop({
containerId: id,
});
await fse.remove(denCfg.configDir);
dendrites.delete(id);
console.log(`Stopped dendrite id ${id}.`);
// cypress deliberately fails if you return 'undefined', so
// return null to signal all is well, and we've handled the task.
return null;
}
async function dendritePineconeStop(id: string): Promise<void> {
return dendriteStop(id);
}
/**
* @type {Cypress.PluginConfig}
*/
export function dendriteDocker(on: PluginEvents, config: PluginConfigOptions) {
on("task", {
dendriteStart,
dendriteStop,
dendritePineconeStart,
dendritePineconeStop,
});
on("after:spec", async (spec) => {
// Cleans up any remaining dendrite instances after a spec run
// This is on the theory that we should avoid re-using dendrite
// instances between spec runs: they should be cheap enough to
// start that we can have a separate one for each spec run or even
// test. If we accidentally re-use dendrites, we could inadvertently
// make our tests depend on each other.
for (const denId of dendrites.keys()) {
console.warn(`Cleaning up dendrite ID ${denId} after ${spec.name}`);
await dendriteStop(denId);
}
});
on("before:run", async () => {
// tidy up old dendrite log files before each run
await fse.emptyDir(path.join("cypress", "dendritelogs"));
});
}

View file

@ -1,378 +0,0 @@
# This is the Dendrite configuration file.
#
# The configuration is split up into sections - each Dendrite component has a
# configuration section, in addition to the "global" section which applies to
# all components.
# The version of the configuration file.
version: 2
# Global Matrix configuration. This configuration applies to all components.
global:
# The domain name of this homeserver.
server_name: localhost
# The path to the signing private key file, used to sign requests and events.
# Note that this is NOT the same private key as used for TLS! To generate a
# signing key, use "./bin/generate-keys --private-key matrix_key.pem".
private_key: matrix_key.pem
# The paths and expiry timestamps (as a UNIX timestamp in millisecond precision)
# to old signing keys that were formerly in use on this domain name. These
# keys will not be used for federation request or event signing, but will be
# provided to any other homeserver that asks when trying to verify old events.
old_private_keys:
# If the old private key file is available:
# - private_key: old_matrix_key.pem
# expired_at: 1601024554498
# If only the public key (in base64 format) and key ID are known:
# - public_key: mn59Kxfdq9VziYHSBzI7+EDPDcBS2Xl7jeUdiiQcOnM=
# key_id: ed25519:mykeyid
# expired_at: 1601024554498
# How long a remote server can cache our server signing key before requesting it
# again. Increasing this number will reduce the number of requests made by other
# servers for our key but increases the period that a compromised key will be
# considered valid by other homeservers.
key_validity_period: 168h0m0s
# Global database connection pool, for PostgreSQL monolith deployments only. If
# this section is populated then you can omit the "database" blocks in all other
# sections. For polylith deployments, or monolith deployments using SQLite databases,
# you must configure the "database" block for each component instead.
# database:
# connection_string: postgresql://username:password@hostname/dendrite?sslmode=disable
# max_open_conns: 90
# max_idle_conns: 5
# conn_max_lifetime: -1
# Configuration for in-memory caches. Caches can often improve performance by
# keeping frequently accessed items (like events, identifiers etc.) in memory
# rather than having to read them from the database.
cache:
# The estimated maximum size for the global cache in bytes, or in terabytes,
# gigabytes, megabytes or kilobytes when the appropriate 'tb', 'gb', 'mb' or
# 'kb' suffix is specified. Note that this is not a hard limit, nor is it a
# memory limit for the entire process. A cache that is too small may ultimately
# provide little or no benefit.
max_size_estimated: 1gb
# The maximum amount of time that a cache entry can live for in memory before
# it will be evicted and/or refreshed from the database. Lower values result in
# easier admission of new cache entries but may also increase database load in
# comparison to higher values, so adjust conservatively. Higher values may make
# it harder for new items to make it into the cache, e.g. if new rooms suddenly
# become popular.
max_age: 1h
# The server name to delegate server-server communications to, with optional port
# e.g. localhost:443
well_known_server_name: ""
# The server name to delegate client-server communications to, with optional port
# e.g. localhost:443
well_known_client_name: ""
# Lists of domains that the server will trust as identity servers to verify third
# party identifiers such as phone numbers and email addresses.
trusted_third_party_id_servers:
- matrix.org
- vector.im
# Disables federation. Dendrite will not be able to communicate with other servers
# in the Matrix federation and the federation API will not be exposed.
disable_federation: false
# Configures the handling of presence events. Inbound controls whether we receive
# presence events from other servers, outbound controls whether we send presence
# events for our local users to other servers.
presence:
enable_inbound: false
enable_outbound: false
# Configures phone-home statistics reporting. These statistics contain the server
# name, number of active users and some information on your deployment config.
# We use this information to understand how Dendrite is being used in the wild.
report_stats:
enabled: false
endpoint: https://matrix.org/report-usage-stats/push
# Server notices allows server admins to send messages to all users on the server.
server_notices:
enabled: false
# The local part, display name and avatar URL (as a mxc:// URL) for the user that
# will send the server notices. These are visible to all users on the deployment.
local_part: "_server"
display_name: "Server Alerts"
avatar_url: ""
# The room name to be used when sending server notices. This room name will
# appear in user clients.
room_name: "Server Alerts"
# Configuration for NATS JetStream
jetstream:
# A list of NATS Server addresses to connect to. If none are specified, an
# internal NATS server will be started automatically when running Dendrite in
# monolith mode. For polylith deployments, it is required to specify the address
# of at least one NATS Server node.
addresses:
# - localhost:4222
# Disable the validation of TLS certificates of NATS. This is
# not recommended in production since it may allow NATS traffic
# to be sent to an insecure endpoint.
disable_tls_validation: false
# Persistent directory to store JetStream streams in. This directory should be
# preserved across Dendrite restarts.
storage_path: ./
# The prefix to use for stream names for this homeserver - really only useful
# if you are running more than one Dendrite server on the same NATS deployment.
topic_prefix: Dendrite
# Configuration for Prometheus metric collection.
metrics:
enabled: false
basic_auth:
username: metrics
password: metrics
# Optional DNS cache. The DNS cache may reduce the load on DNS servers if there
# is no local caching resolver available for use.
dns_cache:
enabled: false
cache_size: 256
cache_lifetime: "5m" # 5 minutes; https://pkg.go.dev/time@master#ParseDuration
# Configuration for the Appservice API.
app_service_api:
# Disable the validation of TLS certificates of appservices. This is
# not recommended in production since it may allow appservice traffic
# to be sent to an insecure endpoint.
disable_tls_validation: false
# Appservice configuration files to load into this homeserver.
config_files:
# - /path/to/appservice_registration.yaml
# Configuration for the Client API.
client_api:
# Prevents new users from being able to register on this homeserver, except when
# using the registration shared secret below.
registration_disabled: false
# Prevents new guest accounts from being created. Guest registration is also
# disabled implicitly by setting 'registration_disabled' above.
guests_disabled: true
# If set, allows registration by anyone who knows the shared secret, regardless
# of whether registration is otherwise disabled.
registration_shared_secret: "{{REGISTRATION_SECRET}}"
# Whether to require reCAPTCHA for registration. If you have enabled registration
# then this is HIGHLY RECOMMENDED to reduce the risk of your homeserver being used
# for coordinated spam attacks.
enable_registration_captcha: false
# Settings for ReCAPTCHA.
recaptcha_public_key: ""
recaptcha_private_key: ""
recaptcha_bypass_secret: ""
# To use hcaptcha.com instead of ReCAPTCHA, set the following parameters, otherwise just keep them empty.
# recaptcha_siteverify_api: "https://hcaptcha.com/siteverify"
# recaptcha_api_js_url: "https://js.hcaptcha.com/1/api.js"
# recaptcha_form_field: "h-captcha-response"
# recaptcha_sitekey_class: "h-captcha"
# TURN server information that this homeserver should send to clients.
turn:
turn_user_lifetime: "5m"
turn_uris:
# - turn:turn.server.org?transport=udp
# - turn:turn.server.org?transport=tcp
turn_shared_secret: ""
# If your TURN server requires static credentials, then you will need to enter
# them here instead of supplying a shared secret. Note that these credentials
# will be visible to clients!
# turn_username: ""
# turn_password: ""
# Settings for rate-limited endpoints. Rate limiting kicks in after the threshold
# number of "slots" have been taken by requests from a specific host. Each "slot"
# will be released after the cooloff time in milliseconds. Server administrators
# and appservice users are exempt from rate limiting by default.
rate_limiting:
enabled: true
threshold: 20
cooloff_ms: 500
exempt_user_ids:
# - "@user:domain.com"
# Configuration for the Federation API.
federation_api:
# How many times we will try to resend a failed transaction to a specific server. The
# backoff is 2**x seconds, so 1 = 2 seconds, 2 = 4 seconds, 3 = 8 seconds etc. Once
# the max retries are exceeded, Dendrite will no longer try to send transactions to
# that server until it comes back to life and connects to us again.
send_max_retries: 16
# Disable the validation of TLS certificates of remote federated homeservers. Do not
# enable this option in production as it presents a security risk!
disable_tls_validation: false
# Disable HTTP keepalives, which also prevents connection reuse. Dendrite will typically
# keep HTTP connections open to remote hosts for 5 minutes as they can be reused much
# more quickly than opening new connections each time. Disabling keepalives will close
# HTTP connections immediately after a successful request but may result in more CPU and
# memory being used on TLS handshakes for each new connection instead.
disable_http_keepalives: false
# Perspective keyservers to use as a backup when direct key fetches fail. This may
# be required to satisfy key requests for servers that are no longer online when
# joining some rooms.
key_perspectives:
- server_name: matrix.org
keys:
- key_id: ed25519:auto
public_key: Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw
- key_id: ed25519:a_RXGa
public_key: l8Hft5qXKn1vfHrg3p4+W8gELQVo8N13JkluMfmn2sQ
# This option will control whether Dendrite will prefer to look up keys directly
# or whether it should try perspective servers first, using direct fetches as a
# last resort.
prefer_direct_fetch: false
database:
connection_string: file:dendrite-federationapi.db
# Configuration for the Media API.
media_api:
# Storage path for uploaded media. May be relative or absolute.
base_path: ./media_store
# The maximum allowed file size (in bytes) for media uploads to this homeserver
# (0 = unlimited). If using a reverse proxy, ensure it allows requests at least
#this large (e.g. the client_max_body_size setting in nginx).
max_file_size_bytes: 10485760
# Whether to dynamically generate thumbnails if needed.
dynamic_thumbnails: false
# The maximum number of simultaneous thumbnail generators to run.
max_thumbnail_generators: 10
# A list of thumbnail sizes to be generated for media content.
thumbnail_sizes:
- width: 32
height: 32
method: crop
- width: 96
height: 96
method: crop
- width: 640
height: 480
method: scale
database:
connection_string: file:dendrite-mediaapi.db
# Configuration for enabling experimental MSCs on this homeserver.
mscs:
mscs:
# - msc2836 # (Threading, see https://github.com/matrix-org/matrix-doc/pull/2836)
# - msc2946 # (Spaces Summary, see https://github.com/matrix-org/matrix-doc/pull/2946)
database:
connection_string: file:dendrite-msc.db
# Configuration for the Sync API.
sync_api:
# This option controls which HTTP header to inspect to find the real remote IP
# address of the client. This is likely required if Dendrite is running behind
# a reverse proxy server.
# real_ip_header: X-Real-IP
# Configuration for the full-text search engine.
search:
# Whether or not search is enabled.
enabled: false
# The path where the search index will be created in.
index_path: "./searchindex"
# The language most likely to be used on the server - used when indexing, to
# ensure the returned results match expectations. A full list of possible languages
# can be found at https://github.com/blevesearch/bleve/tree/master/analysis/lang
language: "en"
database:
connection_string: file:dendrite-syncapi.db
# Configuration for the User API.
user_api:
# The cost when hashing passwords on registration/login. Default: 10. Min: 4, Max: 31
# See https://pkg.go.dev/golang.org/x/crypto/bcrypt for more information.
# Setting this lower makes registration/login consume less CPU resources at the cost
# of security should the database be compromised. Setting this higher makes registration/login
# consume more CPU resources but makes it harder to brute force password hashes. This value
# can be lowered if performing tests or on embedded Dendrite instances (e.g WASM builds).
bcrypt_cost: 10
# The length of time that a token issued for a relying party from
# /_matrix/client/r0/user/{userId}/openid/request_token endpoint
# is considered to be valid in milliseconds.
# The default lifetime is 3600000ms (60 minutes).
# openid_token_lifetime_ms: 3600000
# Users who register on this homeserver will automatically be joined to the rooms listed under "auto_join_rooms" option.
# By default, any room aliases included in this list will be created as a publicly joinable room
# when the first user registers for the homeserver. If the room already exists,
# make certain it is a publicly joinable room, i.e. the join rule of the room must be set to 'public'.
# As Spaces are just rooms under the hood, Space aliases may also be used.
auto_join_rooms:
# - "#main:matrix.org"
account_database:
connection_string: file:dendrite-userapi.db
room_server:
database:
connection_string: file:dendrite-roomserverapi.db
key_server:
database:
connection_string: file:dendrite-keyserverapi.db
relay_api:
database:
connection_string: file:dendrite-relayapi.db
# Configuration for Opentracing.
# See https://github.com/matrix-org/dendrite/tree/master/docs/tracing for information on
# how this works and how to set it up.
tracing:
enabled: false
jaeger:
serviceName: ""
disabled: false
rpc_metrics: false
tags: []
sampler: null
reporter: null
headers: null
baggage_restrictions: null
throttler: null
# Logging configuration. The "std" logging type controls the logs being sent to
# stdout. The "file" logging type controls logs being written to a log folder on
# the disk. Supported log levels are "debug", "info", "warn", "error".
logging:
- type: std
level: debug
- type: file
level: debug
params:
path: ./logs

View file

@ -1,180 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <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 async 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) {
// Run the docker container as our uid:gid to prevent problems with permissions.
if (await isPodman()) {
// Note: this setup is for podman rootless containers.
// In podman, run as root in the container, which maps to the current
// user on the host. This is probably the default since Synapse's
// Dockerfile doesn't specify, but we're being explicit here
// because it's important for the permissions to work.
params.push("-u", "0:0");
// Tell Synapse not to switch UID
params.push("-e", "UID=0");
params.push("-e", "GID=0");
} else {
params.push("-u", `${userInfo.uid}:${userInfo.gid}`);
}
}
const args = [
"run",
"--name",
`${opts.containerName}-${crypto.randomBytes(4).toString("hex")}`,
"-d",
"--rm",
...params,
opts.image,
];
if (opts.cmd) args.push(...opts.cmd);
return new Promise<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());
},
);
});
}
/**
* Detects whether the docker command is actually podman.
* To do this, it looks for "podman" in the output of "docker --help".
*/
export function isPodman(): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
childProcess.execFile("docker", ["--help"], (err, stdout) => {
if (err) reject(err);
else resolve(stdout.toLowerCase().includes("podman"));
});
});
}
/**
* Supply the right hostname to use to talk to the host machine. On Docker this
* is "host.docker.internal" and on Podman this is "host.containers.internal".
*/
export async function hostContainerName() {
return (await isPodman()) ? "host.containers.internal" : "host.docker.internal";
}
/**
* @type {Cypress.PluginConfig}
*/
export function docker(on: PluginEvents, config: PluginConfigOptions) {
on("task", {
dockerRun,
dockerExec,
dockerLogs,
dockerStop,
dockerRm,
dockerIp,
});
}

View file

@ -1,46 +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 installLogsPrinter from "cypress-terminal-report/src/installLogsPrinter";
import { initPlugins } from "cypress-plugin-init";
import PluginEvents = Cypress.PluginEvents;
import PluginConfigOptions = Cypress.PluginConfigOptions;
import { synapseDocker } from "./synapsedocker";
import { dendriteDocker } from "./dendritedocker";
import { webserver } from "./webserver";
import { docker } from "./docker";
import { log } from "./log";
/**
* @type {Cypress.PluginConfig}
*/
export default function (on: PluginEvents, config: PluginConfigOptions) {
initPlugins(on, [docker, synapseDocker, dendriteDocker, webserver, log], config);
installLogsPrinter(on, {
printLogsToConsole: "never",
// write logs to cypress/results/cypresslogs/<spec>.txt
outputRoot: "cypress/results",
outputTarget: {
"cypresslogs|txt": "txt",
},
// strip 'cypress/e2e' from log filenames
specRoot: "cypress/e2e",
});
}

View file

@ -1,35 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import PluginEvents = Cypress.PluginEvents;
import PluginConfigOptions = Cypress.PluginConfigOptions;
export function log(on: PluginEvents, config: PluginConfigOptions) {
on("task", {
log(message: string) {
console.log(message);
return null;
},
table(message: string) {
console.table(message);
return null;
},
});
}

View file

@ -1,91 +0,0 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import PluginEvents = Cypress.PluginEvents;
import PluginConfigOptions = Cypress.PluginConfigOptions;
import { getFreePort } from "../utils/port";
import { dockerIp, dockerRun, dockerStop } from "../docker";
// A cypress plugins to add command to manage an instance of Mailhog in Docker
export interface Instance {
host: string;
smtpPort: number;
httpPort: number;
containerId: string;
}
const instances = new Map<string, Instance>();
// Start a synapse instance: the template must be the name of
// one of the templates in the cypress/plugins/synapsedocker/templates
// directory
async function mailhogStart(): Promise<Instance> {
const smtpPort = await getFreePort();
const httpPort = await getFreePort();
console.log(`Starting mailhog...`);
const containerId = await dockerRun({
image: "mailhog/mailhog:latest",
containerName: `react-sdk-cypress-mailhog`,
params: ["--rm", "-p", `${smtpPort}:1025/tcp`, "-p", `${httpPort}:8025/tcp`],
});
console.log(`Started mailhog on ports smtp=${smtpPort} http=${httpPort}.`);
const host = await dockerIp({ containerId });
const instance: Instance = { smtpPort, httpPort, containerId, host };
instances.set(containerId, instance);
return instance;
}
async function mailhogStop(id: string): Promise<void> {
const synCfg = instances.get(id);
if (!synCfg) throw new Error("Unknown mailhog ID");
await dockerStop({
containerId: id,
});
instances.delete(id);
console.log(`Stopped mailhog id ${id}.`);
// cypress deliberately fails if you return 'undefined', so
// return null to signal all is well, and we've handled the task.
return null;
}
/**
* @type {Cypress.PluginConfig}
*/
export function mailhogDocker(on: PluginEvents, config: PluginConfigOptions) {
on("task", {
mailhogStart,
mailhogStop,
});
on("after:spec", async (spec) => {
// Cleans up any remaining instances after a spec run
for (const synId of instances.keys()) {
console.warn(`Cleaning up synapse ID ${synId} after ${spec.name}`);
await mailhogStop(synId);
}
});
}

View file

@ -1,218 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <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, hostContainerName, isPodman } from "../docker";
import { HomeserverConfig, HomeserverInstance } from "../utils/homeserver";
import { StartHomeserverOpts } from "../../support/homeserver";
// A cypress plugins to add command to start & stop synapses in
// docker with preset templates.
const synapses = new Map<string, HomeserverInstance>();
function randB64Bytes(numBytes: number): string {
return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, "");
}
async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<HomeserverConfig> {
const templateDir = path.join(__dirname, "templates", opts.template);
const stats = await fse.stat(templateDir);
if (!stats?.isDirectory) {
throw new Error(`No such template: ${opts.template}`);
}
const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), "react-sdk-synapsedocker-"));
// copy the contents of the template dir, omitting homeserver.yaml as we'll template that
console.log(`Copy ${templateDir} -> ${tempDir}`);
await fse.copy(templateDir, tempDir, { filter: (f) => path.basename(f) !== "homeserver.yaml" });
const registrationSecret = randB64Bytes(16);
const macaroonSecret = randB64Bytes(16);
const formSecret = randB64Bytes(16);
const port = await getFreePort();
const baseUrl = `http://localhost:${port}`;
// now copy homeserver.yaml, applying substitutions
const templateHomeserver = path.join(templateDir, "homeserver.yaml");
const outputHomeserver = path.join(tempDir, "homeserver.yaml");
console.log(`Gen ${templateHomeserver} -> ${outputHomeserver}`);
let hsYaml = await fse.readFile(templateHomeserver, "utf8");
hsYaml = hsYaml.replace(/{{REGISTRATION_SECRET}}/g, registrationSecret);
hsYaml = hsYaml.replace(/{{MACAROON_SECRET_KEY}}/g, macaroonSecret);
hsYaml = hsYaml.replace(/{{FORM_SECRET}}/g, formSecret);
hsYaml = hsYaml.replace(/{{PUBLIC_BASEURL}}/g, baseUrl);
hsYaml = hsYaml.replace(/{{OAUTH_SERVER_PORT}}/g, opts.oAuthServerPort?.toString());
hsYaml = hsYaml.replace(/{{HOST_DOCKER_INTERNAL}}/g, await hostContainerName());
if (opts.variables) {
let fetchedHostContainer = null;
for (const key in opts.variables) {
let value = String(opts.variables[key]);
if (value === "{{HOST_DOCKER_INTERNAL}}") {
if (!fetchedHostContainer) {
fetchedHostContainer = await hostContainerName();
}
value = fetchedHostContainer;
}
hsYaml = hsYaml.replace(new RegExp("%" + key + "%", "g"), value);
}
}
await fse.writeFile(outputHomeserver, hsYaml);
// now generate a signing key (we could use synapse's config generation for
// this, or we could just do this...)
// NB. This assumes the homeserver.yaml specifies the key in this location
const signingKey = randB64Bytes(32);
const outputSigningKey = path.join(tempDir, "localhost.signing.key");
console.log(`Gen -> ${outputSigningKey}`);
await fse.writeFile(outputSigningKey, `ed25519 x ${signingKey}`);
return {
port,
baseUrl,
configDir: tempDir,
registrationSecret,
};
}
/**
* Start a synapse instance: the template must be the name of
* one of the templates in the cypress/plugins/synapsedocker/templates
* directory.
*
* Any value in opts.variables that is set to `{{HOST_DOCKER_INTERNAL}}'
* will be replaced with 'host.docker.internal' (if we are on Docker) or
* 'host.containers.interal' if we are on Podman.
*/
async function synapseStart(opts: StartHomeserverOpts): Promise<HomeserverInstance> {
const synCfg = await cfgDirFromTemplate(opts);
console.log(`Starting synapse with config dir ${synCfg.configDir}...`);
const dockerSynapseParams = ["--rm", "-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`];
if (await isPodman()) {
// Make host.containers.internal work to allow Synapse to talk to the
// test OIDC server.
dockerSynapseParams.push("--network");
dockerSynapseParams.push("slirp4netns:allow_host_loopback=true");
} else {
// Make host.docker.internal work to allow Synapse to talk to the test
// OIDC server.
dockerSynapseParams.push("--add-host");
dockerSynapseParams.push("host.docker.internal:host-gateway");
}
const synapseId = await dockerRun({
image: "matrixdotorg/synapse:develop",
containerName: `react-sdk-cypress-synapse`,
params: dockerSynapseParams,
cmd: ["run"],
});
console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`);
// Await Synapse healthcheck
await dockerExec({
containerId: synapseId,
params: [
"curl",
"--connect-timeout",
"30",
"--retry",
"30",
"--retry-delay",
"1",
"--retry-all-errors",
"--silent",
"http://localhost:8008/health",
],
});
const synapse: HomeserverInstance = { serverId: synapseId, ...synCfg };
synapses.set(synapseId, synapse);
return synapse;
}
async function synapseStop(id: string): Promise<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,3 +0,0 @@
# Meta-template for synapse templates
To make another template, you can copy this directory

View file

@ -1,72 +0,0 @@
server_name: "localhost"
pid_file: /data/homeserver.pid
# XXX: This won't actually be right: it lets docker allocate an ephemeral port,
# so we have a chicken-and-egg problem
public_baseurl: http://localhost:8008/
# Listener is always port 8008 (configured in the container)
listeners:
- port: 8008
tls: false
bind_addresses: ["::"]
type: http
x_forwarded: true
resources:
- names: [client, federation, consent]
compress: false
# An sqlite in-memory database is fast & automatically wipes each time
database:
name: "sqlite3"
args:
database: ":memory:"
# Needs to be configured to log to the console like a good docker process
log_config: "/data/log.config"
rc_messages_per_second: 10000
rc_message_burst_count: 10000
rc_registration:
per_second: 10000
burst_count: 10000
rc_login:
address:
per_second: 10000
burst_count: 10000
account:
per_second: 10000
burst_count: 10000
failed_attempts:
per_second: 10000
burst_count: 10000
media_store_path: "/data/media_store"
uploads_path: "/data/uploads"
enable_registration: true
enable_registration_without_verification: true
disable_msisdn_registration: false
# These placeholders will be be replaced with values generated at start
registration_shared_secret: "{{REGISTRATION_SECRET}}"
report_stats: false
macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
form_secret: "{{FORM_SECRET}}"
# Signing key must be here: it will be generated to this file
signing_key_path: "/data/localhost.signing.key"
email:
enable_notifs: false
smtp_host: "localhost"
smtp_port: 25
smtp_user: "exampleusername"
smtp_pass: "examplepassword"
require_transport_security: False
notif_from: "Your Friendly %(app)s homeserver <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"
trusted_key_servers:
- server_name: "matrix.org"
suppress_key_server_warning: true

View file

@ -1,50 +0,0 @@
# Log configuration for Synapse.
#
# This is a YAML file containing a standard Python logging configuration
# dictionary. See [1] for details on the valid settings.
#
# Synapse also supports structured logging for machine readable logs which can
# be ingested by ELK stacks. See [2] for details.
#
# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema
# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
# A handler that writes logs to stderr. Unused by default, but can be used
# instead of "buffer" and "file" in the logger handlers.
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
# beware: increasing this to DEBUG will make synapse log sensitive
# information such as access tokens.
level: INFO
twisted:
# We send the twisted logging directly to the file handler,
# to work around https://github.com/matrix-org/synapse/issues/3471
# when using "buffer" logger. Use "console" to log to stderr instead.
handlers: [console]
propagate: false
root:
level: INFO
# Write logs to the `buffer` handler, which will buffer them together in memory,
# then write them to a file.
#
# Replace "buffer" with "console" to log to stderr instead. (Note that you'll
# also need to update the configuration for the `twisted` logger above, in
# this case.)
#
handlers: [console]
disable_existing_loggers: false

View file

@ -1 +0,0 @@
A synapse configured with user privacy consent enabled

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,50 +0,0 @@
# Log configuration for Synapse.
#
# This is a YAML file containing a standard Python logging configuration
# dictionary. See [1] for details on the valid settings.
#
# Synapse also supports structured logging for machine readable logs which can
# be ingested by ELK stacks. See [2] for details.
#
# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema
# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
# A handler that writes logs to stderr. Unused by default, but can be used
# instead of "buffer" and "file" in the logger handlers.
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
# beware: increasing this to DEBUG will make synapse log sensitive
# information such as access tokens.
level: DEBUG
twisted:
# We send the twisted logging directly to the file handler,
# to work around https://github.com/matrix-org/synapse/issues/3471
# when using "buffer" logger. Use "console" to log to stderr instead.
handlers: [console]
propagate: false
root:
level: DEBUG
# Write logs to the `buffer` handler, which will buffer them together in memory,
# then write them to a file.
#
# Replace "buffer" with "console" to log to stderr instead. (Note that you'll
# also need to update the configuration for the `twisted` logger above, in
# this case.)
#
handlers: [console]
disable_existing_loggers: false

View file

@ -1,19 +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 schoen</p>
</body>
</html>

View file

@ -1 +0,0 @@
A synapse configured with user privacy consent disabled

View file

@ -1,94 +0,0 @@
server_name: "localhost"
pid_file: /data/homeserver.pid
public_baseurl: "{{PUBLIC_BASEURL}}"
listeners:
- port: 8008
tls: false
bind_addresses: ["::"]
type: http
x_forwarded: true
resources:
- names: [client]
compress: false
database:
name: "sqlite3"
args:
database: ":memory:"
log_config: "/data/log.config"
rc_messages_per_second: 10000
rc_message_burst_count: 10000
rc_registration:
per_second: 10000
burst_count: 10000
rc_joins:
local:
per_second: 9999
burst_count: 9999
remote:
per_second: 9999
burst_count: 9999
rc_joins_per_room:
per_second: 9999
burst_count: 9999
rc_3pid_validation:
per_second: 1000
burst_count: 1000
rc_invites:
per_room:
per_second: 1000
burst_count: 1000
per_user:
per_second: 1000
burst_count: 1000
rc_login:
address:
per_second: 10000
burst_count: 10000
account:
per_second: 10000
burst_count: 10000
failed_attempts:
per_second: 10000
burst_count: 10000
media_store_path: "/data/media_store"
uploads_path: "/data/uploads"
enable_registration: true
enable_registration_without_verification: true
disable_msisdn_registration: false
registration_shared_secret: "{{REGISTRATION_SECRET}}"
report_stats: false
macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
form_secret: "{{FORM_SECRET}}"
signing_key_path: "/data/localhost.signing.key"
trusted_key_servers:
- server_name: "matrix.org"
suppress_key_server_warning: true
ui_auth:
session_timeout: "300s"
oidc_providers:
- idp_id: test
idp_name: "OAuth test"
issuer: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth"
authorization_endpoint: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth/auth.html"
# the token endpoint receives requests from synapse, rather than the webapp, so needs to escape the docker container.
# Hence, HOST_DOCKER_INTERNAL rather than localhost. This is set to
# host.docker.internal on Docker and host.containers.internal on Podman.
token_endpoint: "http://{{HOST_DOCKER_INTERNAL}}:{{OAUTH_SERVER_PORT}}/oauth/token"
userinfo_endpoint: "http://{{HOST_DOCKER_INTERNAL}}:{{OAUTH_SERVER_PORT}}/oauth/userinfo"
client_id: "synapse"
discover: false
scopes: ["profile"]
skip_verification: true
user_mapping_provider:
config:
display_name_template: "{{ user.name }}"

View file

@ -1,50 +0,0 @@
# Log configuration for Synapse.
#
# This is a YAML file containing a standard Python logging configuration
# dictionary. See [1] for details on the valid settings.
#
# Synapse also supports structured logging for machine readable logs which can
# be ingested by ELK stacks. See [2] for details.
#
# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema
# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
# A handler that writes logs to stderr. Unused by default, but can be used
# instead of "buffer" and "file" in the logger handlers.
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
# beware: increasing this to DEBUG will make synapse log sensitive
# information such as access tokens.
level: DEBUG
twisted:
# We send the twisted logging directly to the file handler,
# to work around https://github.com/matrix-org/synapse/issues/3471
# when using "buffer" logger. Use "console" to log to stderr instead.
handlers: [console]
propagate: false
root:
level: DEBUG
# Write logs to the `buffer` handler, which will buffer them together in memory,
# then write them to a file.
#
# Replace "buffer" with "console" to log to stderr instead. (Note that you'll
# also need to update the configuration for the `twisted` logger above, in
# this case.)
#
handlers: [console]
disable_existing_loggers: false

View file

@ -1 +0,0 @@
A synapse configured to require an email for registration

View file

@ -1,44 +0,0 @@
server_name: "localhost"
pid_file: /data/homeserver.pid
public_baseurl: "{{PUBLIC_BASEURL}}"
listeners:
- port: 8008
tls: false
bind_addresses: ["::"]
type: http
x_forwarded: true
resources:
- names: [client]
compress: false
database:
name: "sqlite3"
args:
database: ":memory:"
log_config: "/data/log.config"
media_store_path: "/data/media_store"
uploads_path: "/data/uploads"
enable_registration: true
registrations_require_3pid:
- email
registration_shared_secret: "{{REGISTRATION_SECRET}}"
report_stats: false
macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
form_secret: "{{FORM_SECRET}}"
signing_key_path: "/data/localhost.signing.key"
trusted_key_servers:
- server_name: "matrix.org"
suppress_key_server_warning: true
ui_auth:
session_timeout: "300s"
email:
smtp_host: "%SMTP_HOST%"
smtp_port: %SMTP_PORT%
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>"
app_name: my_branded_matrix_server

View file

@ -1,50 +0,0 @@
# Log configuration for Synapse.
#
# This is a YAML file containing a standard Python logging configuration
# dictionary. See [1] for details on the valid settings.
#
# Synapse also supports structured logging for machine readable logs which can
# be ingested by ELK stacks. See [2] for details.
#
# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema
# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
# A handler that writes logs to stderr. Unused by default, but can be used
# instead of "buffer" and "file" in the logger handlers.
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
# beware: increasing this to DEBUG will make synapse log sensitive
# information such as access tokens.
level: INFO
twisted:
# We send the twisted logging directly to the file handler,
# to work around https://github.com/matrix-org/synapse/issues/3471
# when using "buffer" logger. Use "console" to log to stderr instead.
handlers: [console]
propagate: false
root:
level: INFO
# Write logs to the `buffer` handler, which will buffer them together in memory,
# then write them to a file.
#
# Replace "buffer" with "console" to log to stderr instead. (Note that you'll
# also need to update the configuration for the `twisted` logger above, in
# this case.)
#
handlers: [console]
disable_existing_loggers: false

View file

@ -1,28 +0,0 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
export interface HomeserverConfig {
configDir: string;
registrationSecret: string;
baseUrl: string;
port: number;
}
export interface HomeserverInstance extends HomeserverConfig {
serverId: string;
}

View file

@ -1,27 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as net from "net";
export async function getFreePort(): Promise<number> {
return new Promise<number>((resolve) => {
const srv = net.createServer();
srv.listen(0, () => {
const port = (<net.AddressInfo>srv.address()).port;
srv.close(() => resolve(port));
});
});
}

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,101 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import "cypress-axe";
import * as axe from "axe-core";
import type { Options } from "cypress-axe";
import Chainable = Cypress.Chainable;
function terminalLog(violations: axe.Result[]): void {
cy.task(
"log",
`${violations.length} accessibility violation${violations.length === 1 ? "" : "s"} ${
violations.length === 1 ? "was" : "were"
} detected`,
);
// pluck specific keys to keep the table readable
const violationData = violations.map(({ id, impact, description, nodes }) => ({
id,
impact,
description,
nodes: nodes.length,
}));
cy.task("table", violationData);
}
Cypress.Commands.overwrite(
"checkA11y",
(
originalFn: Chainable["checkA11y"],
context?: string | Node | axe.ContextObject | undefined,
options: Options = {},
violationCallback?: ((violations: axe.Result[]) => void) | undefined,
skipFailures?: boolean,
): void => {
return originalFn(
context,
{
...options,
rules: {
// Disable contrast checking for now as we have too many issues with it
"color-contrast": {
enabled: false,
},
...options.rules,
},
},
violationCallback ?? terminalLog,
skipFailures,
);
},
);
// Load axe-core into the window under test.
//
// The injectAxe in cypress-axe attempts to load axe via an `eval`. That conflicts with our CSP
// which disallows "unsafe-eval". So, replace it with an implementation that loads it via an
// injected <script> element.
Cypress.Commands.overwrite("injectAxe", (originalFn: Chainable["injectAxe"]): void => {
Cypress.log({ name: "injectAxe" });
// load the minified axe source, and create an intercept to serve it up
cy.readFile("node_modules/axe-core/axe.min.js", { log: false }).then((source) => {
cy.intercept("/_axe", source);
});
// inject a script tag to load it
cy.get("head", { log: false }).then(
(head) =>
new Promise((resolve, reject) => {
const script = document.createElement("script");
script.type = "text/javascript";
script.async = true;
script.onload = resolve;
script.onerror = (_e) => {
// Unfortunately there does not seem to be a way to get a reason for the error.
// The error event is useless.
reject(new Error("Unable to load axe"));
};
script.src = "/_axe";
head.get()[0].appendChild(script);
}),
);
});

View file

@ -1,369 +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 loglevel from "loglevel";
import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import type { SecretStorageKeyDescription } from "matrix-js-sdk/src/secret-storage";
import { HomeserverInstance } from "../plugins/utils/homeserver";
import { Credentials } from "./homeserver";
import { collapseLastLogGroup } from "./log";
import type { Logger } from "matrix-js-sdk/src/logger";
import Chainable = Cypress.Chainable;
interface CreateBotOpts {
/**
* A prefix to use for the userid. If unspecified, "bot_" will be used.
*/
userIdPrefix?: string;
/**
* Whether the bot should automatically accept all invites.
*/
autoAcceptInvites?: boolean;
/**
* The display name to give to that bot user
*/
displayName?: string;
/**
* Whether or not to start the syncing client.
*/
startClient?: boolean;
/**
* Whether or not to generate cross-signing keys
*/
bootstrapCrossSigning?: boolean;
/**
* Whether to use the rust crypto impl. Defaults to false (for now!)
*/
rustCrypto?: boolean;
/**
* Whether or not to bootstrap the secret storage
*/
bootstrapSecretStorage?: boolean;
}
const defaultCreateBotOptions = {
userIdPrefix: "bot_",
autoAcceptInvites: true,
startClient: true,
bootstrapCrossSigning: true,
} as CreateBotOpts;
export interface CypressBot extends MatrixClient {
__cypress_password: string;
__cypress_recovery_key: GeneratedSecretStorageKey;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Returns a new Bot instance
* @param homeserver the instance on which to register the bot user
* @param opts create bot options
*/
getBot(homeserver: HomeserverInstance, opts: CreateBotOpts): Chainable<CypressBot>;
/**
* Returns a new Bot instance logged in as an existing user
* @param homeserver the instance on which to register the bot user
* @param username the username for the bot to log in with
* @param password the password for the bot to log in with
* @param opts create bot options
*/
loginBot(
homeserver: HomeserverInstance,
username: string,
password: string,
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>;
/**
* Send a message as a bot into a room in a specific thread
* @param cli The bot's MatrixClient
* @param threadId the thread within which this message should go
* @param roomId ID of the room to join
* @param message the message body to send
*/
botSendThreadMessage(
cli: MatrixClient,
roomId: string,
threadId: string,
message: string,
): Chainable<ISendEventResponse>;
}
}
}
function setupBotClient(
homeserver: HomeserverInstance,
credentials: Credentials,
opts: CreateBotOpts,
): Chainable<MatrixClient> {
opts = Object.assign({}, defaultCreateBotOptions, opts);
return cy.window({ log: false }).then(
// extra timeout, as this sometimes takes a while
{ timeout: 30_000 },
async (win): Promise<MatrixClient> => {
const logger = getLogger(win, `cypress bot ${credentials.userId}`);
const keys = {};
const getCrossSigningKey = (type: string) => {
return keys[type];
};
const saveCrossSigningKeys = (k: Record<string, Uint8Array>) => {
Object.assign(keys, k);
};
// Store the cached secret storage key and return it when `getSecretStorageKey` is called
let cachedKey: { keyId: string; key: Uint8Array };
const cacheSecretStorageKey = (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => {
cachedKey = {
keyId,
key,
};
};
const getSecretStorageKey = () => Promise.resolve<[string, Uint8Array]>([cachedKey.keyId, cachedKey.key]);
const cryptoCallbacks = {
getCrossSigningKey,
saveCrossSigningKeys,
cacheSecretStorageKey,
getSecretStorageKey,
};
const cli = new win.matrixcs.MatrixClient({
baseUrl: homeserver.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(),
logger: logger,
cryptoCallbacks,
});
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 cli;
}
if (opts.rustCrypto) {
await cli.initRustCrypto({ useIndexedDB: false });
} else {
await cli.initCrypto();
}
cli.setGlobalErrorOnUnknownDevices(false);
await cli.startClient();
if (opts.bootstrapCrossSigning) {
await cli.getCrypto()!.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (func) => {
await func({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: credentials.userId,
},
password: credentials.password,
});
},
});
}
if (opts.bootstrapSecretStorage) {
const passphrase = "new passphrase";
const recoveryKey = await cli.getCrypto().createRecoveryKeyFromPassphrase(passphrase);
Object.assign(cli, { __cypress_recovery_key: recoveryKey });
await cli.getCrypto()!.bootstrapSecretStorage({
setupNewSecretStorage: true,
setupNewKeyBackup: true,
createSecretStorageKey: () => Promise.resolve(recoveryKey),
});
}
return cli;
},
);
}
Cypress.Commands.add("getBot", (homeserver: HomeserverInstance, opts: CreateBotOpts): Chainable<CypressBot> => {
opts = Object.assign({}, defaultCreateBotOptions, opts);
const username = Cypress._.uniqueId(opts.userIdPrefix);
const password = Cypress._.uniqueId("password_");
Cypress.log({
name: "getBot",
message: `Create bot user ${username} with opts ${JSON.stringify(opts)}`,
groupStart: true,
});
return cy
.registerUser(homeserver, username, password, opts.displayName)
.then((credentials) => {
return setupBotClient(homeserver, credentials, opts);
})
.then((client): Chainable<CypressBot> => {
Object.assign(client, { __cypress_password: password });
Cypress.log({ groupEnd: true, emitOnly: true });
collapseLastLogGroup();
return cy.wrap(client as CypressBot, { log: false });
});
});
Cypress.Commands.add(
"loginBot",
(
homeserver: HomeserverInstance,
username: string,
password: string,
opts: CreateBotOpts,
): Chainable<MatrixClient> => {
opts = Object.assign({}, defaultCreateBotOptions, { bootstrapCrossSigning: false }, opts);
Cypress.log({
name: "loginBot",
message: `log in as ${username} with opts ${JSON.stringify(opts)}`,
groupStart: true,
});
return cy
.loginUser(homeserver, username, password)
.then((credentials) => {
return setupBotClient(homeserver, credentials, opts);
})
.then((res) => {
Cypress.log({ groupEnd: true, emitOnly: true });
collapseLastLogGroup();
cy.wrap(res, { log: false });
});
},
);
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 },
);
},
);
Cypress.Commands.add(
"botSendThreadMessage",
(cli: MatrixClient, roomId: string, threadId: string, message: string): Chainable<ISendEventResponse> => {
return cy.wrap(
cli.sendMessage(roomId, threadId, {
msgtype: "m.text",
body: message,
}),
{ log: false },
);
},
);
/** Get a Logger implementation based on `loglevel` with the given logger name */
function getLogger(win: Cypress.AUTWindow, loggerName: string): Logger {
const logger = loglevel.getLogger(loggerName);
// If this is the first time this logger has been returned, turn it into a `Logger` and set the default level
if (!("getChild" in logger)) {
logger["getChild"] = (namespace: string) => getLogger(win, loggerName + ":" + namespace);
logger.methodFactory = makeLogMethodFactory(win);
logger.setLevel(loglevel.levels.DEBUG);
}
return logger as unknown as Logger;
}
/**
* Helper for getLogger: a factory for loglevel method factories.
*/
function makeLogMethodFactory(win: Cypress.AUTWindow): loglevel.MethodFactory {
function methodFactory(
methodName: loglevel.LogLevelNames,
level: loglevel.LogLevelNumbers,
loggerName: string | symbol,
): loglevel.LoggingMethod {
// here's the actual log method, which implements `Logger.info`, `Logger.debug`, etc.
return function (first: any, ...rest): void {
// include the logger name in the output...
first = `\x1B[31m[${loggerName.toString()}]\x1B[m ${first.toString()}`;
// ... and delegate to the corresponding method in the console of the application under test.
// Doing so (rather than using the global `console`) ensures that the output is collected
// by the `cypress-terminal-report` plugin.
const console = win.console;
if (methodName in console) {
console[methodName](first, ...rest);
} else {
console.log(first, ...rest);
}
};
}
return methodFactory;
}

View file

@ -1,218 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import type {
MatrixClient,
Room,
IContent,
FileType,
Upload,
UploadOpts,
ICreateRoomOpts,
ISendEventResponse,
} from "matrix-js-sdk/src/matrix";
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[]>;
/**
* 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) => {
const res = await cli.invite(roomId, userId);
Cypress.log({ name: "inviteUser", message: `sent invite in ${roomId} for ${userId}` });
return res;
});
});
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("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.findByRole("button", { name: "More options" }).click();
});
return cy.get(".mx_MessageComposer_Menu");
});
// Needed to make this file a module
export {};

View file

@ -1,51 +0,0 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/* Intercept requests to `config.json`, so that we can test against a known configuration.
*
* If we don't do this, we end up testing against the Element config for develop.element.io, which then means
* we make requests to the live `matrix.org`, which makes our tests dependent on matrix.org being up and responsive.
*/
import { isRustCryptoEnabled } from "./util";
const CONFIG_JSON = {
// This is deliberately quite a minimal config.json, so that we can test that the default settings
// actually work.
//
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
},
// the location tests want a map style url.
map_style_url: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx",
};
beforeEach(() => {
const configJson = CONFIG_JSON;
// configure element to use rust crypto if the env var tells us so
if (isRustCryptoEnabled()) {
configJson["features"] = {
feature_rust_crypto: true,
};
}
cy.intercept({ method: "GET", pathname: "/config.json" }, { body: configJson });
});

View file

@ -1,59 +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 "@percy/cypress";
import "cypress-real-events";
import "@testing-library/cypress/add-commands";
import installLogsCollector from "cypress-terminal-report/src/installLogsCollector";
import "./config.json";
import "./homeserver";
import "./login";
import "./labs";
import "./client";
import "./settings";
import "./bot";
import "./clipboard";
import "./util";
import "./app";
import "./percy";
import "./webserver";
import "./views";
import "./iframes";
import "./composer";
import "./axe";
import "./promise";
installLogsCollector({
// specify the types of logs to collect (and report to the node console at the end of the test)
collectTypes: [
"cons:log",
"cons:info",
"cons:warn",
"cons:error",
// most of our logs go through `loglevel`, which sets `logger.log` to be an alias of `logger.debug`.
// Hence, if we want to capture `logger.log` lines, we need to enable `cons:debug` here.
"cons:debug",
"cy:log",
"cy:xhr",
"cy:fetch",
"cy:request",
"cy:intercept",
"cy:command",
],
});

View file

@ -1,155 +0,0 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import * as crypto from "crypto";
import Chainable = Cypress.Chainable;
import AUTWindow = Cypress.AUTWindow;
import { HomeserverInstance } from "../plugins/utils/homeserver";
export interface StartHomeserverOpts {
/** path to template within cypress/plugins/{homeserver}docker/template/ directory. */
template: string;
/** Port of an OAuth server to configure the homeserver to use */
oAuthServerPort?: number;
/** Additional variables to inject into the configuration template **/
variables?: Record<string, string | number>;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Start a homeserver instance with a given config template.
*
* @param opts: either the template path (within cypress/plugins/{homeserver}docker/template/), or
* an options object
*
* If any of opts.variables has the special value
* '{{HOST_DOCKER_INTERNAL}}', it will be replaced by
* 'host.docker.interal' if we are on Docker, or
* 'host.containers.internal' on Podman.
*/
startHomeserver(opts: string | StartHomeserverOpts): Chainable<HomeserverInstance>;
/**
* Custom command wrapping task:{homeserver}Stop whilst preventing uncaught exceptions
* for if Homeserver stopping races with the app's background sync loop.
*
* @param homeserver the homeserver instance returned by {homeserver}Start (e.g. synapseStart).
*/
stopHomeserver(homeserver: HomeserverInstance): Chainable<AUTWindow>;
/**
* Register a user on the given Homeserver using the shared registration secret.
* @param homeserver the homeserver instance returned by start{Homeserver}
* @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(
homeserver: HomeserverInstance,
username: string,
password: string,
displayName?: string,
): Chainable<Credentials>;
}
}
}
function startHomeserver(opts: string | StartHomeserverOpts): Chainable<HomeserverInstance> {
const homeserverName = Cypress.env("HOMESERVER");
if (typeof opts === "string") {
opts = { template: opts };
}
return cy.task<HomeserverInstance>(homeserverName + "Start", opts, { log: false }).then((x) => {
Cypress.log({ name: "startHomeserver", message: `Started homeserver instance ${x.serverId}` });
});
}
function stopHomeserver(homeserver?: HomeserverInstance): Chainable<AUTWindow> {
if (!homeserver) return;
// Navigate away from app to stop the background network requests which will race with Homeserver shutting down
return cy.window({ log: false }).then((win) => {
win.location.href = "about:blank";
const homeserverName = Cypress.env("HOMESERVER");
cy.task(homeserverName + "Stop", homeserver.serverId);
});
}
export interface Credentials {
accessToken: string;
userId: string;
deviceId: string;
homeServer: string;
password: string;
}
function registerUser(
homeserver: HomeserverInstance,
username: string,
password: string,
displayName?: string,
): Chainable<Credentials> {
const url = `${homeserver.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", homeserver.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,
password: password,
}));
}
Cypress.Commands.add("startHomeserver", startHomeserver);
Cypress.Commands.add("stopHomeserver", stopHomeserver);
Cypress.Commands.add("registerUser", registerUser);

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 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,45 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Chainable = Cypress.Chainable;
/// <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,45 +0,0 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
// secret undocumented options to Cypress.log
interface LogConfig {
/** begin a new log group; remember to match with `groupEnd` */
groupStart: boolean;
/** end a log group that was previously started with `groupStart` */
groupEnd: boolean;
/** suppress regular output: useful for closing a log group without writing another log line */
emitOnly: boolean;
}
}
}
/** collapse the last open log group in the Cypress UI
*
* Credit to https://j1000.github.io/blog/2022/10/27/enhanced_cypress_logging.html
*/
export function collapseLastLogGroup() {
const openExpanders = window.top.document.getElementsByClassName("command-expander-is-open");
const numExpanders = openExpanders.length;
const el = openExpanders[numExpanders - 1];
if (el) el.parentElement.click();
}

View file

@ -1,169 +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 { HomeserverInstance } from "../plugins/utils/homeserver";
import { collapseLastLogGroup } from "./log";
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
* @param userIdPrefix optional prefix to use for the generated user id. If unspecified, `user_` will be
* useed.
*/
initTestUser(
homeserver: HomeserverInstance,
displayName: string,
prelaunchFn?: () => void,
userIdPrefix?: string,
): 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: HomeserverInstance, username: string, password: string): Chainable<UserCredentials>;
}
}
}
// eslint-disable-next-line max-len
Cypress.Commands.add(
"loginUser",
(homeserver: HomeserverInstance, username: string, password: string): Chainable<UserCredentials> => {
const url = `${homeserver.baseUrl}/_matrix/client/v3/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",
(
homeserver: HomeserverInstance,
displayName: string,
prelaunchFn?: () => void,
userIdPrefix = "user_",
): Chainable<UserCredentials> => {
Cypress.log({ name: "initTestUser", groupStart: true });
// 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(userIdPrefix);
const password = Cypress._.uniqueId("password_");
return cy
.registerUser(homeserver, username, password, displayName)
.then(() => {
return cy.loginUser(homeserver, username, password);
})
.then((response) => {
cy.log(`Registered test user ${username} with displayname ${displayName}`);
cy.window({ log: false }).then((win) => {
// Seed the localStorage with the required credentials
win.localStorage.setItem("mx_hs_url", homeserver.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("/", {
onBeforeLoad(win) {
// reset notification permissions so we have predictable behaviour
// of notifications toast
// @ts-ignore allow setting default
cy.stub(win.Notification, "permission", "default");
},
})
.then(() => {
// wait for the app to load
return cy.get(".mx_MatrixChat", { timeout: 30000 });
})
.then(() => {
Cypress.log({
groupEnd: true,
emitOnly: true,
});
collapseLastLogGroup();
})
.then(() => ({
password,
username,
accessToken: response.accessToken,
userId: response.userId,
deviceId: response.deviceId,
homeServer: response.homeServer,
}));
});
},
);

View file

@ -1,54 +0,0 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import mailhog from "mailhog";
import Chainable = Cypress.Chainable;
import { Instance } from "../plugins/mailhog";
export interface Mailhog {
api: mailhog.API;
instance: Instance;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
startMailhog(): Chainable<Mailhog>;
stopMailhog(instance: Mailhog): Chainable<void>;
}
}
}
Cypress.Commands.add("startMailhog", (): Chainable<Mailhog> => {
return cy.task<Instance>("mailhogStart", { log: false }).then((x) => {
Cypress.log({ name: "startHomeserver", message: `Started mailhog instance ${x.containerId}` });
return {
api: mailhog({
host: "localhost",
port: x.httpPort,
}),
instance: x,
};
});
});
Cypress.Commands.add("stopMailhog", (mailhog: Mailhog): Chainable<void> => {
return cy.task("mailhogStop", mailhog.instance.containerId);
});

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 { 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;
allowSpinners?: boolean;
}
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) => {
if (!options?.allowSpinners) {
// Await spinners to vanish
cy.get(".mx_Spinner", { log: false }).should("not.exist");
// But like really no more spinners please
cy.get(".mx_Spinner", { log: false }).should("not.exist");
// Await inline spinners to vanish
cy.get(".mx_InlineSpinner", { log: false }).should("not.exist");
}
let selector = subject.selector;
// cy.findByTestId sets the selector to `findByTestId(<testId>)`
// which is not usable as a scope
if (selector.startsWith("findByTestId")) {
selector = `[data-testid="${subject.attr("data-testid")}"]`;
}
cy.percySnapshot(name, {
domTransformation: (documentClone) => scope(documentClone, 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 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.
*/
/// <reference types="cypress" />
import Chainable = Cypress.Chainable;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Utility wrapper around promises to help control flow in tests
* Calls `fn` function `tries` times, with a sleep of `interval` between calls.
* Ensure you do not rely on any effects of calling any `cy.*` functions within the body of `fn`
* as the calls will not happen until after waitForPromise returns.
* @param fn the function to retry
* @param tries the number of tries to call it
* @param interval the time interval between tries
*/
waitForPromise(fn: () => Promise<unknown>, tries?: number, interval?: number): Chainable<unknown>;
}
}
}
function waitForPromise(fn: () => Promise<unknown>, tries = 10, interval = 1000): Chainable<unknown> {
return cy.then(
() =>
new Cypress.Promise(async (resolve, reject) => {
for (let i = 0; i < tries; i++) {
try {
const v = await fn();
resolve(v);
} catch {
await new Cypress.Promise((resolve) => setTimeout(resolve, interval));
}
}
reject();
}),
);
}
Cypress.Commands.add("waitForPromise", waitForPromise);
export {};

View file

@ -1,190 +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 Loggable = Cypress.Loggable;
import Timeoutable = Cypress.Timeoutable;
import Withinable = Cypress.Withinable;
import Shadow = Cypress.Shadow;
import type { SettingLevel } from "../../src/settings/SettingLevel";
import ApplicationWindow = Cypress.ApplicationWindow;
export enum Filter {
People = "people",
PublicRooms = "public_rooms",
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Returns the SettingsStore
*/
getSettingsStore(): Chainable<ApplicationWindow["mxSettingsStore"] | undefined>;
/**
* 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>>;
/**
* 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>>;
/**
* 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>;
/**
* 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>>;
}
}
}
Cypress.Commands.add("getSettingsStore", (): Chainable<ApplicationWindow["mxSettingsStore"]> => {
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: ApplicationWindow["mxSettingsStore"]) => {
return cy.wrap(store.setValue(name, roomId, level, value));
});
},
);
Cypress.Commands.add("openUserMenu", (): Chainable<JQuery<HTMLElement>> => {
cy.findByRole("button", { name: "User menu" }).click();
return cy.get(".mx_ContextualMenu");
});
Cypress.Commands.add("openUserSettings", (tab?: string): Chainable<JQuery<HTMLElement>> => {
cy.openUserMenu().within(() => {
cy.findByRole("menuitem", { name: "All settings" }).click();
});
return cy.get(".mx_UserSettingsDialog").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.findByRole("button", { name: "Close dialog" }).click();
});
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", options).findByRole("textbox", { name: "Search" });
},
);
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);
},
);
// Needed to make this file a module
export {};

View file

@ -1,27 +0,0 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
/**
* Assert that a toast with the given title exists, and return it
*
* @param expectedTitle - Expected title of the test
* @returns a Chainable for the DOM element of the toast
*/
export function getToast(expectedTitle: string): Cypress.Chainable<JQuery> {
return cy.contains(".mx_Toast_toast h2", expectedTitle).should("exist").closest(".mx_Toast_toast");
}

View file

@ -1,84 +0,0 @@
/*
Copyright 2022-2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import EventEmitter from "events";
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]> }>;
}
}
}
/**
* @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 resultArray = [];
// as each command completes, store the result in the corresponding location of resultArray.
for (let i = 0; i < commands.length; i++) {
commands[i].then((val) => {
resultArray[i] = val;
});
}
// add an entry to the log which, when clicked, will write the results to the console.
Cypress.log({
name: "all",
consoleProps: () => ({ Results: resultArray }),
});
// return a chainable which wraps the resultArray. Although this doesn't have a direct dependency on the input
// commands, cypress won't process it until the commands that precede it on the command queue (which must include
// the input commands) have passed.
return cy.wrap(resultArray, { log: false });
};
/**
* Check if Cypress has been configured to enable rust crypto, and bail out if so.
*/
export function skipIfRustCrypto() {
if (isRustCryptoEnabled()) {
cy.log("Skipping due to rust crypto");
//@ts-ignore: 'state' is a secret internal command
cy.state("runnable").skip();
}
}
/**
* Determine if Cypress has been configured to enable rust crypto (by checking the environment variable)
*/
export function isRustCryptoEnabled(): boolean {
return !!Cypress.env("RUST_CRYPTO");
}
/**
* Returns a Promise which will resolve when the given event emitter emits a given event
*/
export function emitPromise(e: EventEmitter, k: string | symbol) {
return new Promise((r) => e.once(k, r));
}

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 {
/**
* Opens the given room by name. The room must be visible in the
* room list, but the room list may be folded horizontally, and the
* room may contain unread messages.
*
* @param name The exact room name to find and click on/open.
*/
viewRoomByName(name: string): Chainable<JQuery<HTMLElement>>;
/**
* Opens the given room by room ID.
*
* This works by browsing to `/#/room/${id}`, so it will also work for room aliases.
*
* @param id
*/
viewRoomById(id: string): void;
}
}
}
Cypress.Commands.add("viewRoomByName", (name: string): Chainable<JQuery<HTMLElement>> => {
// We look for the room inside the room list, which is a tree called Rooms.
//
// There are 3 cases:
// - the room list is folded:
// then the aria-label on the room tile is the name (with nothing extra)
// - the room list is unfolder and the room has messages:
// then the aria-label contains the unread count, but the title of the
// div inside the titleContainer equals the room name
// - the room list is unfolded and the room has no messages:
// then the aria-label is the name and so is the title of a div
//
// So by matching EITHER title=name OR aria-label=name we find this exact
// room in all three cases.
return cy.findByRole("tree", { name: "Rooms" }).find(`[title="${name}"],[aria-label="${name}"]`).first().click();
});
Cypress.Commands.add("viewRoomById", (id: string): void => {
cy.visit(`/#/room/${id}`);
});
// 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,13 +0,0 @@
{
"compilerOptions": {
"target": "es2016",
"jsx": "react",
"lib": ["es2021", "dom", "dom.iterable"],
"types": ["cypress", "cypress-axe", "@percy/cypress", "@testing-library/cypress"],
"resolveJsonModule": true,
"esModuleInterop": true,
"moduleResolution": "node",
"module": "es2022"
},
"include": ["**/*.ts"]
}

View file

@ -20,4 +20,4 @@
# Testing # Testing
- [Cypress end to end](cypress.md) - [Playwright end to end](playwright.md)

View file

@ -1,281 +0,0 @@
# Cypress in Element Web
# 🚨 We are moving away from Cypress in favour of Playwright.
Please do not write any new tests in Cypress and check out [the Playwright docs](playwright.md).
## Contents
- How to run the tests
- How the tests work
- How to write great Cypress tests
- Visual testing
## Running the Tests
Our Cypress tests run automatically as part of our CI along with our other tests,
on every pull request and on every merge to develop & master.
However the Cypress tests are run, an element-web must be running on
http://localhost:8080 (this is configured in `cypress.json`) - this is what will
be tested. When running Cypress tests yourself, the standard `yarn start` from the
element-web project is fine: leave it running it a different terminal as you would
when developing.
The tests use Docker to launch Homeserver (Synapse or Dendrite) instances to test against, so you'll also
need to have Docker installed and working in order to run the Cypress tests.
There are a few different ways to run the tests yourself. The simplest is to run:
```
docker pull matrixdotorg/synapse:develop
yarn run test:cypress
```
This will run the Cypress tests once, non-interactively.
Note: you don't need to run the `docker pull` command every time, but you should
do it regularly to ensure you are running against an up-to-date Synapse.
You can also run individual tests this way too, as you'd expect:
```
yarn run test:cypress --spec cypress/e2e/1-register/register.spec.ts
```
Cypress also has its own UI that you can use to run and debug the tests.
To launch it:
```
yarn run test:cypress:open
```
### Matching the CI environment
In our Continuous Integration environment, we run the Cypress tests in the
Chrome browser, and with the latest Synapse image from Docker Hub.
In some rare cases, tests behave differently between different browsers, so if
you see CI failures for the Cypress tests, but those tests work OK on your local
machine, try running them in Chrome like this:
```bash
yarn run test:cypress --browser=chrome
```
(Use `--browser=chromium` if you'd prefer to use Chromium.)
If you launch the interactive UI you can choose the browser you want to use. To
match the CI setup, choose Chrome.
Note that you will need to have Chrome installed on your system to run the tests
inside those browsers, whereas the default is to use Electron, which is included
within the Cypress dependency.
Another cause of inconsistency between local and CI is the Synapse version. The
first time you run the tests, they automatically fetch the latest Docker image
of Synapse, but this won't update again unless you do it explicitly. To update
the Synapse you are using, run:
```
docker pull matrixdotorg/synapse:develop
```
and then run the tests as normal.
### Running with Rust cryptography
`matrix-js-sdk` is currently in the
[process](https://github.com/vector-im/element-web/issues/21972) of being
updated to replace its end-to-end encryption implementation to use the [Matrix
Rust SDK](https://github.com/matrix-org/matrix-rust-sdk). This is not currently
enabled by default, but it is possible to have Cypress configure Element to use
the Rust crypto implementation by setting the environment variable
`CYPRESS_RUST_CRYPTO=1`.
## How the Tests Work
Everything Cypress-related lives in the `cypress/` subdirectory of react-sdk
as is typical for Cypress tests. Likewise, tests live in `cypress/e2e`.
`cypress/plugins/synapsedocker` contains a Cypress plugin that starts instances
of Synapse in Docker containers. These synapses are what Element-web runs against
in the Cypress tests.
Synapse can be launched with different configurations in order to test element
in different configurations. `cypress/plugins/synapsedocker/templates` contains
template configuration files for each different configuration.
Each test suite can then launch whatever Synapse instances it needs in whatever
configurations.
Note that although tests should stop the Homeserver instances after running and the
plugin also stop any remaining instances after all tests have run, it is possible
to be left with some stray containers if, for example, you terminate a test such
that the `after()` does not run and also exit Cypress uncleanly. All the containers
it starts are prefixed, so they are easy to recognise. They can be removed safely.
After each test run, logs from the Synapse instances are saved in `cypress/synapselogs`
with each instance in a separate directory named after its ID. These logs are removed
at the start of each test run.
## Writing Tests
Mostly this is the same advice as for writing any other Cypress test: the Cypress
docs are well worth a read if you're not already familiar with Cypress testing, eg.
https://docs.cypress.io/guides/references/best-practices. To avoid your tests being
flaky it is also recommended to give https://docs.cypress.io/guides/core-concepts/retry-ability
a read.
### Getting a Synapse
The key difference is in starting Synapse instances. Tests use this plugin via
`cy.startHomeserver()` to provide a Homeserver instance to log into:
```javascript
cy.startHomeserver("consent").then((result) => {
homeserver = result;
});
```
This returns an object with information about the Homeserver instance, including what port
it was started on and the ID that needs to be passed to shut it down again. It also
returns the registration shared secret (`registrationSecret`) that can be used to
register users via the REST API. The Homeserver has been ensured ready to go by awaiting
its internal health-check.
Homeserver instances should be reasonably cheap to start (you may see the first one take a
while as it pulls the Docker image), so it's generally expected that tests will start a
Homeserver instance for each test suite, i.e. in `before()`, and then tear it down in `after()`.
To later destroy your Homeserver you should call `stopHomeserver`, passing the HomeserverInstance
object you received when starting it.
```javascript
cy.stopHomeserver(homeserver);
```
### Synapse Config Templates
When a Synapse instance is started, it's given a config generated from one of the config
templates in `cypress/plugins/synapsedocker/templates`. There are a couple of special files
in these templates:
- `homeserver.yaml`:
Template substitution happens in this file. Template variables are:
- `REGISTRATION_SECRET`: The secret used to register users via the REST API.
- `MACAROON_SECRET_KEY`: Generated each time for security
- `FORM_SECRET`: Generated each time for security
- `PUBLIC_BASEURL`: The localhost url + port combination the synapse is accessible at
- `localhost.signing.key`: A signing key is auto-generated and saved to this file.
Config templates should not contain a signing key and instead assume that one will exist
in this file.
All other files in the template are copied recursively to `/data/`, so the file `foo.html`
in a template can be referenced in the config as `/data/foo.html`.
### Logging In
There exists a basic utility to start the app with a random user already logged in:
```javascript
cy.initTestUser(homeserver, "Jeff");
```
It takes the HomeserverInstance you received from `startHomeserver` and a display name for your test user.
This custom command will register a random userId using the registrationSecret with a random password
and the given display name. The returned Chainable will contain details about the credentials for if
they are needed for User-Interactive Auth or similar but localStorage will already be seeded with them
and the app loaded (path `/`).
The internals of how this custom command run may be swapped out later,
but the signature can be maintained for simpler maintenance.
### Joining a Room
Many tests will also want to start with the client in a room, ready to send & receive messages. Best
way to do this may be to get an access token for the user and use this to create a room with the REST
API before logging the user in. You can make use of `cy.getBot(homeserver)` and `cy.getClient()` to do this.
### Convenience APIs
We should probably end up with convenience APIs that wrap the homeserver creation, logging in and room
creation that can be called to set up tests.
### Try to write tests from the users's perspective
Like for instance a user will not look for a button by querying a CSS selector. Instead you should work
with roles / labels etc.. You can make use of `cy.findBy…` queries provided by
[Cypress Testing Library](https://github.com/testing-library/cypress-testing-library).
### Using matrix-js-sdk
Due to the way we run the Cypress tests in CI, at this time you can only use the matrix-js-sdk module
exposed on `window.matrixcs`. This has the limitation that it is only accessible with the app loaded.
This may be revisited in the future.
## Good Test Hygiene
This section mostly summarises general good Cypress testing practice, and should not be news to anyone
already familiar with Cypress.
1. Test a well-isolated unit of functionality. The more specific, the easier it will be to tell what's
wrong when they fail.
1. Don't depend on state from other tests: any given test should be able to run in isolation.
1. Try to avoid driving the UI for anything other than the UI you're trying to test. e.g. if you're
testing that the user can send a reaction to a message, it's best to send a message using a REST
API, then react to it using the UI, rather than using the element-web UI to send the message.
1. Avoid explicit waits. `cy.get()` will implicitly wait for the specified element to appear and
all assertions are retired until they either pass or time out, so you should never need to
manually wait for an element.
- For example, for asserting about editing an already-edited message, you can't wait for the
'edited' element to appear as there was already one there, but you can assert that the body
of the message is what is should be after the second edit and this assertion will pass once
it becomes true. You can then assert that the 'edited' element is still in the DOM.
- You can also wait for other things like network requests in the
browser to complete (https://docs.cypress.io/guides/guides/network-requests#Waiting).
Needing to wait for things can also be because of race conditions in the app itself, which ideally
shouldn't be there!
This is a small selection - the Cypress best practices guide, linked above, has more good advice, and we
should generally try to adhere to them.
## Screenshot testing with Percy
**⚠️ Percy is disabled while we're figuring out https://github.com/vector-im/wat-internal/issues/36**
**and https://github.com/vector-im/wat-internal/issues/56. We're hoping to turn it back on or switch**
**to an alternative in the future.**
We also support visual testing via [Percy](https://percy.io). Within many of our
Cypress tests you can see lines calling `cy.percySnapshot()`. This creates a
screenshot and uses Percy to check whether it has changed from the last time
this test was run.
It can help to pass `percyCSS` in as the 2nd argument to `percySnapshot` to hide
elements that vary (e.g. timestamps). See the existing code for examples of
this. (Note: it is also possible for team members to mark certain parts of a
screenshot to be ignored. This is done within the Percy UI.)
Percy screenshots are created using custom renderers based on Safari, Firefox,
Chrome and Edge. Each `percySnapshot` actually creates 8 screenshots (4
browsers, 2 sizes). Since we have a limited budget for Percy screenshots, by
default we only run Percy once per day against the `develop` branch, based on a
nightly build at approximately 04:00 UTC every day. (The schedule is defined in
[element-web.yaml](../.github/workflows/element-web.yaml) and the Percy tests are
enabled/disabled in [cypress.yaml](../.github/workflows/cypress.yaml).)
If your pull request makes visual changes, you are encouraged to request Percy
to run by adding the label `X-Needs-Percy` to the PR, these will only run in
the merge queue to save snapshots. This will help us find any
visual bugs or validate visual changes at the time they are made, instead of
having to figure it out later after the nightly build. If you don't have
permission to add a label, please ask your reviewer to do it. Note: it's best to
add this label when the change is nearly ready, because the screenshots will be
re-created every time you make a change to your PR.
Some UI elements render differently between test runs, such as BaseAvatar when
there is no avatar set, choosing a colour from the theme palette based on the
hash of the user/room's Matrix ID. To avoid this creating flaky tests we can use
the `@media only percy` CSS query to override the variable colour into a fixed one
for tests where it is not feasible to fix the underlying identifiers issued by the
server. See https://docs.percy.io/docs/percy-specific-css#percy-css-media-query.

View file

@ -46,13 +46,11 @@
"start:all": "echo THIS IS FOR LEGACY PURPOSES ONLY. && yarn start:build", "start:all": "echo THIS IS FOR LEGACY PURPOSES ONLY. && yarn start:build",
"start:build": "babel src -w -s -d lib --verbose --extensions \".ts,.js\"", "start:build": "babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
"lint": "yarn lint:types && yarn lint:js && yarn lint:style && yarn lint:workflows", "lint": "yarn lint:types && yarn lint:js && yarn lint:style && yarn lint:workflows",
"lint:js": "eslint --max-warnings 0 src test cypress playwright && prettier --check .", "lint:js": "eslint --max-warnings 0 src test playwright && prettier --check .",
"lint:js-fix": "eslint --fix src test cypress playwright && prettier --log-level=warn --write .", "lint:js-fix": "eslint --fix src test playwright && prettier --log-level=warn --write .",
"lint:types": "tsc --noEmit --jsx react && tsc --noEmit --jsx react -p cypress && tsc --noEmit --jsx react -p playwright", "lint:types": "tsc --noEmit --jsx react && tsc --noEmit --jsx react -p playwright",
"lint:style": "stylelint \"res/css/**/*.pcss\"", "lint:style": "stylelint \"res/css/**/*.pcss\"",
"test": "jest", "test": "jest",
"test:cypress": "cypress run",
"test:cypress:open": "cypress open",
"test:playwright": "playwright test", "test:playwright": "playwright test",
"test:playwright:open": "yarn test:playwright --ui", "test:playwright:open": "yarn test:playwright --ui",
"test:playwright:screenshots": "yarn test:playwright:screenshots:build && yarn test:playwright:screenshots:run", "test:playwright:screenshots": "yarn test:playwright:screenshots:build && yarn test:playwright:screenshots:run",
@ -152,12 +150,8 @@
"@babel/preset-typescript": "^7.12.7", "@babel/preset-typescript": "^7.12.7",
"@babel/register": "^7.12.10", "@babel/register": "^7.12.10",
"@casualbot/jest-sonar-reporter": "2.2.7", "@casualbot/jest-sonar-reporter": "2.2.7",
"@knapsack-pro/cypress": "^8.0.1",
"@peculiar/webcrypto": "^1.4.3", "@peculiar/webcrypto": "^1.4.3",
"@percy/cli": "^1.11.0",
"@percy/cypress": "^3.1.2",
"@playwright/test": "^1.40.1", "@playwright/test": "^1.40.1",
"@testing-library/cypress": "^9.0.0",
"@testing-library/dom": "^9.0.0", "@testing-library/dom": "^9.0.0",
"@testing-library/jest-dom": "^6.0.0", "@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^12.1.5", "@testing-library/react": "^12.1.5",
@ -194,12 +188,6 @@
"axe-core": "4.8.3", "axe-core": "4.8.3",
"babel-jest": "^29.0.0", "babel-jest": "^29.0.0",
"blob-polyfill": "^7.0.0", "blob-polyfill": "^7.0.0",
"cypress": "^12.0.0",
"cypress-axe": "^1.0.0",
"cypress-multi-reporters": "^1.6.1",
"cypress-plugin-init": "^0.0.8",
"cypress-real-events": "^1.7.1",
"cypress-terminal-report": "^5.3.2",
"eslint": "8.56.0", "eslint": "8.56.0",
"eslint-config-google": "^0.14.0", "eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",

View file

@ -139,12 +139,12 @@ test.describe("Audio player", () => {
}); });
test("should be correctly rendered - light theme", async ({ page, app }) => { test("should be correctly rendered - light theme", async ({ page, app }) => {
await uploadFile(page, "cypress/fixtures/1sec-long-name-audio-file.ogg"); await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (light theme)"); await takeSnapshots(page, app, "Selected EventTile of audio player (light theme)");
}); });
test("should be correctly rendered - light theme with monospace font", async ({ page, app }) => { test("should be correctly rendered - light theme with monospace font", async ({ page, app }) => {
await uploadFile(page, "cypress/fixtures/1sec-long-name-audio-file.ogg"); await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (light theme, monospace font)", true); // Enable monospace await takeSnapshots(page, app, "Selected EventTile of audio player (light theme, monospace font)", true); // Enable monospace
}); });
@ -160,7 +160,7 @@ test.describe("Audio player", () => {
await app.closeDialog(); await app.closeDialog();
await uploadFile(page, "cypress/fixtures/1sec-long-name-audio-file.ogg"); await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (high contrast)"); await takeSnapshots(page, app, "Selected EventTile of audio player (high contrast)");
}); });
@ -169,13 +169,13 @@ test.describe("Audio player", () => {
// Enable dark theme // Enable dark theme
await app.settings.setValue("theme", null, SettingLevel.ACCOUNT, "dark"); await app.settings.setValue("theme", null, SettingLevel.ACCOUNT, "dark");
await uploadFile(page, "cypress/fixtures/1sec-long-name-audio-file.ogg"); await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (dark theme)"); await takeSnapshots(page, app, "Selected EventTile of audio player (dark theme)");
}); });
test("should play an audio file", async ({ page, app }) => { test("should play an audio file", async ({ page, app }) => {
await uploadFile(page, "cypress/fixtures/1sec.ogg"); await uploadFile(page, "playwright/sample-files/1sec.ogg");
// Assert that the audio player is rendered // Assert that the audio player is rendered
const container = page.locator(".mx_EventTile_last .mx_AudioPlayer_container"); const container = page.locator(".mx_EventTile_last .mx_AudioPlayer_container");
@ -197,7 +197,7 @@ test.describe("Audio player", () => {
}); });
test("should support downloading an audio file", async ({ page, app }) => { test("should support downloading an audio file", async ({ page, app }) => {
await uploadFile(page, "cypress/fixtures/1sec.ogg"); await uploadFile(page, "playwright/sample-files/1sec.ogg");
const downloadPromise = page.waitForEvent("download"); const downloadPromise = page.waitForEvent("download");
@ -212,7 +212,7 @@ test.describe("Audio player", () => {
}); });
test("should support replying to audio file with another audio file", async ({ page, app }) => { test("should support replying to audio file with another audio file", async ({ page, app }) => {
await uploadFile(page, "cypress/fixtures/1sec.ogg"); await uploadFile(page, "playwright/sample-files/1sec.ogg");
// Assert the audio player is rendered // Assert the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
@ -223,7 +223,7 @@ test.describe("Audio player", () => {
await tile.getByRole("button", { name: "Reply", exact: true }).click(); await tile.getByRole("button", { name: "Reply", exact: true }).click();
// Reply to the player with another audio file // Reply to the player with another audio file
await uploadFile(page, "cypress/fixtures/1sec.ogg"); await uploadFile(page, "playwright/sample-files/1sec.ogg");
// Assert that the audio player is rendered // Assert that the audio player is rendered
await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible(); await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible();
@ -250,7 +250,7 @@ test.describe("Audio player", () => {
await tile.getByRole("button", { name: "Reply", exact: true }).click(); await tile.getByRole("button", { name: "Reply", exact: true }).click();
}; };
await uploadFile(page, "cypress/fixtures/upload-first.ogg"); await uploadFile(page, "playwright/sample-files/upload-first.ogg");
// Assert that the audio player is rendered // Assert that the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
@ -258,7 +258,7 @@ test.describe("Audio player", () => {
await clickButtonReply(); await clickButtonReply();
// Reply to the player with another audio file // Reply to the player with another audio file
await uploadFile(page, "cypress/fixtures/upload-second.ogg"); await uploadFile(page, "playwright/sample-files/upload-second.ogg");
// Assert that the audio player is rendered // Assert that the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
@ -266,7 +266,7 @@ test.describe("Audio player", () => {
await clickButtonReply(); await clickButtonReply();
// Reply to the player with yet another audio file to create a reply chain // Reply to the player with yet another audio file to create a reply chain
await uploadFile(page, "cypress/fixtures/upload-third.ogg"); await uploadFile(page, "playwright/sample-files/upload-third.ogg");
// Assert that the audio player is rendered // Assert that the audio player is rendered
await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible(); await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible();
@ -299,7 +299,7 @@ test.describe("Audio player", () => {
}); });
test("should be rendered, play, and support replying on a thread", async ({ page, app }) => { test("should be rendered, play, and support replying on a thread", async ({ page, app }) => {
await uploadFile(page, "cypress/fixtures/1sec-long-name-audio-file.ogg"); await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
// On the main timeline // On the main timeline
const messageList = page.locator(".mx_RoomView_MessageList"); const messageList = page.locator(".mx_RoomView_MessageList");

View file

@ -58,9 +58,9 @@ test.describe("FilePanel", () => {
test("should list tiles on the panel", async ({ page }) => { test("should list tiles on the panel", async ({ page }) => {
// Upload multiple files // Upload multiple files
await uploadFile(page, "cypress/fixtures/riot.png"); // Image await uploadFile(page, "playwright/sample-files/riot.png"); // Image
await uploadFile(page, "cypress/fixtures/1sec.ogg"); // Audio await uploadFile(page, "playwright/sample-files/1sec.ogg"); // Audio
await uploadFile(page, "cypress/fixtures/matrix-org-client-versions.json"); // JSON await uploadFile(page, "playwright/sample-files/matrix-org-client-versions.json"); // JSON
const roomViewBody = page.locator(".mx_RoomView_body"); const roomViewBody = page.locator(".mx_RoomView_body");
// Assert that all of the file were uploaded and rendered // Assert that all of the file were uploaded and rendered
@ -143,7 +143,7 @@ test.describe("FilePanel", () => {
test("should render the audio player and play the audio file on the panel", async ({ page }) => { test("should render the audio player and play the audio file on the panel", async ({ page }) => {
// Upload an image file // Upload an image file
await uploadFile(page, "cypress/fixtures/1sec.ogg"); await uploadFile(page, "playwright/sample-files/1sec.ogg");
const audioBody = page.locator( const audioBody = page.locator(
".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container", ".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container",
@ -178,7 +178,7 @@ test.describe("FilePanel", () => {
const size = "1.12 KB"; // actual file size in kibibytes (1024 bytes) const size = "1.12 KB"; // actual file size in kibibytes (1024 bytes)
// Upload a file // Upload a file
await uploadFile(page, "cypress/fixtures/matrix-org-client-versions.json"); await uploadFile(page, "playwright/sample-files/matrix-org-client-versions.json");
const tile = page.locator(".mx_FilePanel .mx_EventTile"); const tile = page.locator(".mx_FilePanel .mx_EventTile");
// Assert that the file size is displayed in kibibytes, not kilobytes (1000 bytes) // Assert that the file size is displayed in kibibytes, not kilobytes (1000 bytes)
@ -192,7 +192,7 @@ test.describe("FilePanel", () => {
test.describe("download", () => { test.describe("download", () => {
test("should download an image via the link on the panel", async ({ page, context }) => { test("should download an image via the link on the panel", async ({ page, context }) => {
// Upload an image file // Upload an image file
await uploadFile(page, "cypress/fixtures/riot.png"); await uploadFile(page, "playwright/sample-files/riot.png");
// Detect the image file on the panel // Detect the image file on the panel
const imageBody = page.locator( const imageBody = page.locator(

View file

@ -71,7 +71,7 @@ test.describe("Spaces", () => {
await contextMenu await contextMenu
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') .locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.setInputFiles("cypress/fixtures/riot.png"); .setInputFiles("playwright/sample-files/riot.png");
await contextMenu.getByRole("textbox", { name: "Name" }).fill("Let's have a Riot"); await contextMenu.getByRole("textbox", { name: "Name" }).fill("Let's have a Riot");
await expect(contextMenu.getByRole("textbox", { name: "Address" })).toHaveValue("lets-have-a-riot"); await expect(contextMenu.getByRole("textbox", { name: "Address" })).toHaveValue("lets-have-a-riot");
await contextMenu.getByRole("textbox", { name: "Description" }).fill("This is a space to reminisce Riot.im!"); await contextMenu.getByRole("textbox", { name: "Description" }).fill("This is a space to reminisce Riot.im!");
@ -102,7 +102,7 @@ test.describe("Spaces", () => {
await menu await menu
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') .locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.setInputFiles("cypress/fixtures/riot.png"); .setInputFiles("playwright/sample-files/riot.png");
await menu.getByRole("textbox", { name: "Name" }).fill("This is not a Riot"); await menu.getByRole("textbox", { name: "Name" }).fill("This is not a Riot");
await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible(); await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible();
await menu.getByRole("textbox", { name: "Description" }).fill("This is a private space of mourning Riot.im..."); await menu.getByRole("textbox", { name: "Description" }).fill("This is a private space of mourning Riot.im...");
@ -147,7 +147,7 @@ test.describe("Spaces", () => {
await menu await menu
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') .locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.setInputFiles("cypress/fixtures/riot.png"); .setInputFiles("playwright/sample-files/riot.png");
await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible(); await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible();
await menu.getByRole("textbox", { name: "Description" }).fill("This is a personal space to mourn Riot.im..."); await menu.getByRole("textbox", { name: "Description" }).fill("This is a personal space to mourn Riot.im...");
await menu.getByRole("textbox", { name: "Name" }).fill("This is my Riot"); await menu.getByRole("textbox", { name: "Name" }).fill("This is my Riot");

View file

@ -31,8 +31,8 @@ const AVATAR_SIZE = 30;
const AVATAR_RESIZE_METHOD = "crop"; const AVATAR_RESIZE_METHOD = "crop";
const ROOM_NAME = "Test room"; const ROOM_NAME = "Test room";
const OLD_AVATAR = fs.readFileSync("cypress/fixtures/riot.png"); const OLD_AVATAR = fs.readFileSync("playwright/sample-files/riot.png");
const NEW_AVATAR = fs.readFileSync("cypress/fixtures/element.png"); const NEW_AVATAR = fs.readFileSync("playwright/sample-files/element.png");
const OLD_NAME = "Alan"; const OLD_NAME = "Alan";
const NEW_NAME = "Alan (away)"; const NEW_NAME = "Alan (away)";
@ -139,7 +139,7 @@ test.describe("Timeline", () => {
), ),
).toBeVisible(); ).toBeVisible();
// wait for the date separator to appear to have a stable percy snapshot // wait for the date separator to appear to have a stable screenshot
await expect(page.locator(".mx_TimelineSeparator")).toHaveText("today"); await expect(page.locator(".mx_TimelineSeparator")).toHaveText("today");
await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("configured-room-irc-layout.png"); await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("configured-room-irc-layout.png");
@ -684,7 +684,7 @@ test.describe("Timeline", () => {
// Upload a file from the message composer // Upload a file from the message composer
await page await page
.locator(".mx_MessageComposer_actions input[type='file']") .locator(".mx_MessageComposer_actions input[type='file']")
.setInputFiles("cypress/fixtures/matrix-org-client-versions.json"); .setInputFiles("playwright/sample-files/matrix-org-client-versions.json");
// Click "Upload" button // Click "Upload" button
await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click(); await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
@ -707,7 +707,7 @@ test.describe("Timeline", () => {
"**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*", "**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*",
async (route) => { async (route) => {
await route.fulfill({ await route.fulfill({
path: "cypress/fixtures/riot.png", path: "playwright/sample-files/riot.png",
}); });
}, },
); );
@ -1048,7 +1048,7 @@ test.describe("Timeline", () => {
.getByText(OLD_NAME + " created and configured the room."), .getByText(OLD_NAME + " created and configured the room."),
).toBeVisible(); ).toBeVisible();
// Set the display name to "LONG_STRING 2" in order to avoid a warning in Percy tests from being triggered // Set the display name to "LONG_STRING 2" in order to avoid screenshot tests from failing
// due to the generated random mxid being displayed inside the GELS summary. // due to the generated random mxid being displayed inside the GELS summary.
await app.client.setDisplayName(`${LONG_STRING} 2`); await app.client.setDisplayName(`${LONG_STRING} 2`);
@ -1089,7 +1089,7 @@ test.describe("Timeline", () => {
// Make sure the strings do not overflow on IRC layout // Make sure the strings do not overflow on IRC layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
// Scroll to the bottom to have Percy take a snapshot of the whole viewport // Scroll to the bottom to take a snapshot of the whole viewport
await app.timeline.scrollToBottom(); await app.timeline.scrollToBottom();
// Assert that both avatar in the introduction and the last message are visible at the same time // Assert that both avatar in the introduction and the last message are visible at the same time
await expect(page.locator(".mx_NewRoomIntro .mx_BaseAvatar")).toBeVisible(); await expect(page.locator(".mx_NewRoomIntro .mx_BaseAvatar")).toBeVisible();

View file

@ -59,7 +59,7 @@ export class SlidingSyncProxy {
const postgresId = await this.postgresDocker.run({ const postgresId = await this.postgresDocker.run({
image: "postgres", image: "postgres",
containerName: "react-sdk-cypress-sliding-sync-postgres", containerName: "react-sdk-playwright-sliding-sync-postgres",
params: ["--rm", "-e", `POSTGRES_PASSWORD=${PG_PASSWORD}`], params: ["--rm", "-e", `POSTGRES_PASSWORD=${PG_PASSWORD}`],
}); });
@ -72,7 +72,7 @@ export class SlidingSyncProxy {
console.log(new Date(), "starting proxy container...", SLIDING_SYNC_PROXY_TAG); console.log(new Date(), "starting proxy container...", SLIDING_SYNC_PROXY_TAG);
const containerId = await this.proxyDocker.run({ const containerId = await this.proxyDocker.run({
image: "ghcr.io/matrix-org/sliding-sync:" + SLIDING_SYNC_PROXY_TAG, image: "ghcr.io/matrix-org/sliding-sync:" + SLIDING_SYNC_PROXY_TAG,
containerName: "react-sdk-cypress-sliding-sync-proxy", containerName: "react-sdk-playwright-sliding-sync-proxy",
params: [ params: [
"--rm", "--rm",
"-p", "-p",

View file

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -141,16 +141,3 @@ limitations under the License.
} }
} }
} }
@media only Percy {
/* Remove the list style in percy tests for screenshot consistency */
:is(ul, ol) {
padding: 0 !important;
margin: 0 !important;
list-style: none !important;
.mx_EventTile_last {
padding: 0 !important;
}
}
}

View file

@ -5,12 +5,12 @@ sonar.organization=matrix-org
#sonar.sourceEncoding=UTF-8 #sonar.sourceEncoding=UTF-8
sonar.sources=src,res sonar.sources=src,res
sonar.tests=test,cypress sonar.tests=test,playwright
sonar.exclusions=__mocks__,docs sonar.exclusions=__mocks__,docs
sonar.cpd.exclusions=src/i18n/strings/*.json sonar.cpd.exclusions=src/i18n/strings/*.json
sonar.typescript.tsconfigPath=./tsconfig.json sonar.typescript.tsconfigPath=./tsconfig.json
sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.javascript.lcov.reportPaths=coverage/lcov.info
# instrumentation is disabled on SessionLock # instrumentation is disabled on SessionLock
sonar.coverage.exclusions=test/**/*,cypress/**/*,src/components/views/dialogs/devtools/**/*,src/utils/SessionLock.ts sonar.coverage.exclusions=test/**/*,playwright/**/*,src/components/views/dialogs/devtools/**/*,src/utils/SessionLock.ts
sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml

View file

@ -481,7 +481,7 @@ class NotifierClass {
const room = MatrixClientPeg.safeGet().getRoom(roomId); const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) { if (!room) {
// e.g we are in the process of joining a room. // e.g we are in the process of joining a room.
// Seen in the cypress lazy-loading test. // Seen in the Playwright lazy-loading test.
return; return;
} }

View file

@ -250,7 +250,7 @@ export default class ChangePassword extends React.Component<IProps, IState> {
const newPassword = this.state.newPassword; const newPassword = this.state.newPassword;
const confirmPassword = this.state.newPasswordConfirm; const confirmPassword = this.state.newPasswordConfirm;
try { try {
// TODO: We can remove this check (but should add some Cypress tests to // TODO: We can remove this check (but should add some Playwright tests to
// sanity check this flow). This logic is redundant with the input field // sanity check this flow). This logic is redundant with the input field
// validation we do and `verifyFieldsBeforeSubmit()` above. See // validation we do and `verifyFieldsBeforeSubmit()` above. See
// https://github.com/matrix-org/matrix-react-sdk/pull/10615#discussion_r1167364214 // https://github.com/matrix-org/matrix-react-sdk/pull/10615#discussion_r1167364214

1205
yarn.lock

File diff suppressed because it is too large Load diff