From 05cc5f62ddcd7f28567e6d64799a01c18dda4721 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 1 Aug 2022 08:47:13 +0200 Subject: [PATCH 1/2] test UserSettingsDialog (#9118) --- src/components/structures/TabbedView.tsx | 7 +- .../views/dialogs/UserSettingsDialog.tsx | 6 +- .../views/dialogs/UserSettingsDialog-test.tsx | 157 ++++++++++++++++++ .../UserSettingsDialog-test.tsx.snap | 141 ++++++++++++++++ test/test-utils/client.ts | 14 ++ 5 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 test/components/views/dialogs/UserSettingsDialog-test.tsx create mode 100644 test/components/views/dialogs/__snapshots__/UserSettingsDialog-test.tsx.snap diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx index 91e64946adad..a55e51407369 100644 --- a/src/components/structures/TabbedView.tsx +++ b/src/components/structures/TabbedView.tsx @@ -117,7 +117,12 @@ export default class TabbedView extends React.Component { const label = _t(tab.label); return ( - + { tabIcon } { label } diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx index 8f5e1a610886..6d4fe15fcc48 100644 --- a/src/components/views/dialogs/UserSettingsDialog.tsx +++ b/src/components/views/dialogs/UserSettingsDialog.tsx @@ -46,7 +46,7 @@ interface IState { } export default class UserSettingsDialog extends React.Component { - private mjolnirWatcher: string; + private mjolnirWatcher: string | undefined; constructor(props) { super(props); @@ -61,7 +61,7 @@ export default class UserSettingsDialog extends React.Component } public componentWillUnmount(): void { - SettingsStore.unwatchSetting(this.mjolnirWatcher); + this.mjolnirWatcher && SettingsStore.unwatchSetting(this.mjolnirWatcher); } private mjolnirChanged: CallbackFn = (settingName, roomId, atLevel, newValue) => { @@ -70,7 +70,7 @@ export default class UserSettingsDialog extends React.Component }; private getTabs() { - const tabs = []; + const tabs: Tab[] = []; tabs.push(new Tab( UserTab.General, diff --git a/test/components/views/dialogs/UserSettingsDialog-test.tsx b/test/components/views/dialogs/UserSettingsDialog-test.tsx new file mode 100644 index 000000000000..50dc3f97aa1c --- /dev/null +++ b/test/components/views/dialogs/UserSettingsDialog-test.tsx @@ -0,0 +1,157 @@ +/* +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 React, { ReactElement } from 'react'; +import { render } from '@testing-library/react'; +import { mocked } from 'jest-mock'; + +import SettingsStore, { CallbackFn } from '../../../../src/settings/SettingsStore'; +import SdkConfig from '../../../../src/SdkConfig'; +import { UserTab } from '../../../../src/components/views/dialogs/UserTab'; +import UserSettingsDialog from '../../../../src/components/views/dialogs/UserSettingsDialog'; +import { IDialogProps } from '../../../../src/components/views/dialogs/IDialogProps'; +import { + getMockClientWithEventEmitter, + mockClientMethodsUser, + mockClientMethodsServer, + mockPlatformPeg, +} from '../../../test-utils'; +import { UIFeature } from '../../../../src/settings/UIFeature'; +import { SettingLevel } from '../../../../src/settings/SettingLevel'; + +mockPlatformPeg({ + supportsSpellCheckSettings: jest.fn().mockReturnValue(false), + getAppVersion: jest.fn().mockResolvedValue('1'), +}); + +jest.mock('../../../../src/settings/SettingsStore', () => ({ + getValue: jest.fn(), + getValueAt: jest.fn(), + canSetValue: jest.fn(), + monitorSetting: jest.fn(), + watchSetting: jest.fn(), + unwatchSetting: jest.fn(), + getFeatureSettingNames: jest.fn(), + getBetaInfo: jest.fn(), +})); + +jest.mock('../../../../src/SdkConfig', () => ({ + get: jest.fn(), +})); + +describe('', () => { + const userId = '@alice:server.org'; + const mockSettingsStore = mocked(SettingsStore); + const mockSdkConfig = mocked(SdkConfig); + getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + ...mockClientMethodsServer(), + }); + + const defaultProps = { onFinished: jest.fn() }; + const getComponent = (props: Partial = {}): ReactElement => ( + + ); + + beforeEach(() => { + jest.clearAllMocks(); + mockSettingsStore.getValue.mockReturnValue(false); + mockSettingsStore.getFeatureSettingNames.mockReturnValue([]); + mockSdkConfig.get.mockReturnValue({ brand: 'Test' }); + }); + + const getActiveTabLabel = (container) => container.querySelector('.mx_TabbedView_tabLabel_active').textContent; + const getActiveTabHeading = (container) => container.querySelector('.mx_SettingsTab_heading').textContent; + + it('should render general settings tab when no initialTabId', () => { + const { container } = render(getComponent()); + + expect(getActiveTabLabel(container)).toEqual('General'); + expect(getActiveTabHeading(container)).toEqual('General'); + }); + + it('should render initial tab when initialTabId is set', () => { + const { container } = render(getComponent({ initialTabId: UserTab.Help })); + + expect(getActiveTabLabel(container)).toEqual('Help & About'); + expect(getActiveTabHeading(container)).toEqual('Help & About'); + }); + + it('should render general tab if initialTabId tab cannot be rendered', () => { + // mjolnir tab is only rendered in some configs + const { container } = render(getComponent({ initialTabId: UserTab.Mjolnir })); + + expect(getActiveTabLabel(container)).toEqual('General'); + expect(getActiveTabHeading(container)).toEqual('General'); + }); + + it('renders tabs correctly', () => { + const { container } = render(getComponent()); + expect(container.querySelectorAll('.mx_TabbedView_tabLabel')).toMatchSnapshot(); + }); + + it('renders ignored users tab when feature_mjolnir is enabled', () => { + mockSettingsStore.getValue.mockImplementation((settingName) => settingName === "feature_mjolnir"); + const { getByTestId } = render(getComponent()); + expect(getByTestId(`settings-tab-${UserTab.Mjolnir}`)).toBeTruthy(); + }); + + it('renders voip tab when voip is enabled', () => { + mockSettingsStore.getValue.mockImplementation((settingName) => settingName === UIFeature.Voip); + const { getByTestId } = render(getComponent()); + expect(getByTestId(`settings-tab-${UserTab.Voice}`)).toBeTruthy(); + }); + + it('renders labs tab when show_labs_settings is enabled in config', () => { + mockSdkConfig.get.mockImplementation((configName) => configName === "show_labs_settings"); + const { getByTestId } = render(getComponent()); + expect(getByTestId(`settings-tab-${UserTab.Labs}`)).toBeTruthy(); + }); + + it('renders labs tab when some feature is in beta', () => { + mockSettingsStore.getFeatureSettingNames.mockReturnValue(['feature_beta_setting', 'feature_just_normal_labs']); + mockSettingsStore.getBetaInfo.mockImplementation( + (settingName) => settingName === 'feature_beta_setting' ? {} as any : undefined, + ); + const { getByTestId } = render(getComponent()); + expect(getByTestId(`settings-tab-${UserTab.Labs}`)).toBeTruthy(); + }); + + it('watches feature_mjolnir setting', () => { + let watchSettingCallback: CallbackFn = jest.fn(); + + mockSettingsStore.watchSetting.mockImplementation((settingName, roomId, callback) => { + watchSettingCallback = callback; + return `mock-watcher-id-${settingName}`; + }); + + const { queryByTestId, unmount } = render(getComponent()); + expect(queryByTestId(`settings-tab-${UserTab.Mjolnir}`)).toBeFalsy(); + + expect(mockSettingsStore.watchSetting.mock.calls[0][0]).toEqual('feature_mjolnir'); + + // call the watch setting callback + watchSettingCallback("feature_mjolnir", '', SettingLevel.ACCOUNT, true, true); + + // tab is rendered now + expect(queryByTestId(`settings-tab-${UserTab.Mjolnir}`)).toBeTruthy(); + + unmount(); + + // unwatches mjolnir after unmount + expect(mockSettingsStore.unwatchSetting).toHaveBeenCalledWith('mock-watcher-id-feature_mjolnir'); + }); +}); diff --git a/test/components/views/dialogs/__snapshots__/UserSettingsDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/UserSettingsDialog-test.tsx.snap new file mode 100644 index 000000000000..fb887a304df3 --- /dev/null +++ b/test/components/views/dialogs/__snapshots__/UserSettingsDialog-test.tsx.snap @@ -0,0 +1,141 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders tabs correctly 1`] = ` +NodeList [ +
+ + + General + +
, +
+ + + Appearance + +
, +
+ + + Notifications + +
, +
+ + + Preferences + +
, +
+ + + Keyboard + +
, +
+ + + Sidebar + +
, +
+ + + Security & Privacy + +
, +
+ + + Labs + +
, +
+ + + Help & About + +
, +] +`; diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index ea21b81dd8a9..453856eb26e2 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -68,6 +68,8 @@ export const mockClientMethodsUser = (userId = '@alice:domain') => ({ isGuest: jest.fn().mockReturnValue(false), mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'), credentials: { userId }, + getThreePids: jest.fn().mockResolvedValue({ threepids: [] }), + getAccessToken: jest.fn(), }); /** @@ -82,3 +84,15 @@ export const mockClientMethodsEvents = () => ({ decryptEventIfNeeded: jest.fn(), getPushActionsForEvent: jest.fn(), }); + +/** + * Returns basic mocked client methods related to server support + */ +export const mockClientMethodsServer = (): Partial, unknown>> => ({ + doesServerSupportSeparateAddAndBind: jest.fn(), + getIdentityServerUrl: jest.fn(), + getHomeserverUrl: jest.fn(), + getCapabilities: jest.fn().mockReturnValue({}), + doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false), +}); + From d5db131eef99f01ddfe00dbfce90b2beb62e5634 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 1 Aug 2022 08:31:14 +0100 Subject: [PATCH 2/2] Spike AXE A11Y testing in Cypress (#9111) * Spike AXE A11Y testing in Cypress * Fix NewRoomIntro breaking html/aria list rules * Fix HeaderButtons breaking aria role semantics rules * missing type * Switch left panel from aside to nav and include space panel * Give the page a main heading of the room name when viewing a room * Use header landmark on RoomHeader * Improve aria attributes on composer when autocomplete is closed * Fix aria-owns on RoomHeader * Give Spinner an aria role * Give server picker help button an aria label * Improve auth aria attributes and semantics * Improve heading semantics in use case selection screen * Fix autocomplete attribute to be valid * Fix heading semantics on login page * Improve Cypress axe testing * Add axe tests * Stop synapse after the timeline tests * Await spinners to fade before percy snapshotting timeline tests * Improve naming of plugin * Update snapshots * Fix accidental heading change * Fix double synapse stoppage * Fix Cypress timeline avatar assertions to be DPI agnostic * Fix aria attributes on date separators * delint * Update snapshots * Revert style change * Skip redundant call --- cypress/e2e/1-register/register.spec.ts | 9 +++ cypress/e2e/14-timeline/timeline.spec.ts | 30 +++++---- cypress/e2e/2-login/login.spec.ts | 3 + cypress/plugins/index.ts | 2 + cypress/plugins/log.ts | 35 +++++++++++ cypress/support/axe.ts | 61 +++++++++++++++++++ cypress/support/e2e.ts | 1 + cypress/tsconfig.json | 6 +- package.json | 2 + res/css/views/auth/_AuthBody.pcss | 10 +-- .../views/dialogs/_ServerPickerDialog.pcss | 4 +- res/css/views/elements/_ServerPicker.pcss | 2 +- res/css/views/elements/_UseCaseSelection.pcss | 2 +- res/css/views/messages/_DateSeparator.pcss | 5 +- src/components/structures/LeftPanel.tsx | 4 +- src/components/structures/LoggedInView.tsx | 4 +- .../structures/auth/CompleteSecurity.tsx | 4 +- .../structures/auth/ForgotPassword.tsx | 2 +- src/components/structures/auth/Login.tsx | 4 +- .../structures/auth/Registration.tsx | 14 ++--- src/components/structures/auth/SoftLogout.tsx | 12 ++-- .../auth/header/AuthHeaderDisplay.tsx | 2 +- src/components/views/auth/AuthBody.tsx | 4 +- src/components/views/auth/AuthFooter.tsx | 4 +- src/components/views/auth/AuthHeaderLogo.tsx | 4 +- src/components/views/auth/PasswordLogin.tsx | 2 +- .../views/dialogs/ServerPickerDialog.tsx | 3 +- .../views/elements/ServerPicker.tsx | 9 ++- src/components/views/elements/Spinner.tsx | 1 + .../views/elements/UseCaseSelection.tsx | 2 +- .../views/messages/DateSeparator.tsx | 8 +-- .../views/right_panel/HeaderButtons.tsx | 2 +- .../views/rooms/BasicMessageComposer.tsx | 6 +- src/components/views/rooms/NewRoomIntro.tsx | 5 +- src/components/views/rooms/RoomHeader.tsx | 30 ++++++--- src/i18n/strings/en_EN.json | 1 + .../__snapshots__/DateSeparator-test.tsx.snap | 16 ++--- .../FontScalingPanel-test.tsx.snap | 1 + .../ModuleComponents-test.tsx.snap | 1 + yarn.lock | 10 +++ 40 files changed, 244 insertions(+), 83 deletions(-) create mode 100644 cypress/plugins/log.ts create mode 100644 cypress/support/axe.ts diff --git a/cypress/e2e/1-register/register.spec.ts b/cypress/e2e/1-register/register.spec.ts index 2bbc23e0c558..5e9cfd3e5fbd 100644 --- a/cypress/e2e/1-register/register.spec.ts +++ b/cypress/e2e/1-register/register.spec.ts @@ -33,9 +33,12 @@ describe("Registration", () => { }); it("registers an account and lands on the home screen", () => { + cy.injectAxe(); + cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click(); cy.get(".mx_ServerPickerDialog_continue").should("be.visible"); cy.percySnapshot("Server Picker"); + cy.checkA11y(); cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); cy.get(".mx_ServerPickerDialog_continue").click(); @@ -46,6 +49,7 @@ describe("Registration", () => { // Hide the server text as it contains the randomly allocated Synapse port const percyCSS = ".mx_ServerPicker_server { visibility: hidden !important; }"; cy.percySnapshot("Registration", { percyCSS }); + cy.checkA11y(); cy.get("#mx_RegistrationForm_username").type("alice"); cy.get("#mx_RegistrationForm_password").type("totally a great password"); @@ -55,16 +59,21 @@ describe("Registration", () => { cy.get(".mx_RegistrationEmailPromptDialog").should("be.visible"); cy.percySnapshot("Registration email prompt", { percyCSS }); + cy.checkA11y(); cy.get(".mx_RegistrationEmailPromptDialog button.mx_Dialog_primary").click(); cy.stopMeasuring("create-account"); cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy").should("be.visible"); cy.percySnapshot("Registration terms prompt", { percyCSS }); + cy.checkA11y(); cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy input").click(); cy.startMeasuring("from-submit-to-home"); cy.get(".mx_InteractiveAuthEntryComponents_termsSubmit").click(); + cy.get(".mx_UseCaseSelection_skip").should("exist"); + cy.percySnapshot("Use-case selection screen"); + cy.checkA11y(); cy.get(".mx_UseCaseSelection_skip .mx_AccessibleButton").click(); cy.url().should('contain', '/#/home'); diff --git a/cypress/e2e/14-timeline/timeline.spec.ts b/cypress/e2e/14-timeline/timeline.spec.ts index bb9d7148aa9b..2778ab56dda4 100644 --- a/cypress/e2e/14-timeline/timeline.spec.ts +++ b/cypress/e2e/14-timeline/timeline.spec.ts @@ -20,7 +20,6 @@ import { MessageEvent } from "matrix-events-sdk"; import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import type { EventType } from "matrix-js-sdk/src/@types/event"; -import type { MatrixClient } from "matrix-js-sdk/src/client"; import { SynapseInstance } from "../../plugins/synapsedocker"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; @@ -46,10 +45,14 @@ const expectDisplayName = (e: JQuery, displayName: string): void => }; const expectAvatar = (e: JQuery, avatarUrl: string): void => { - cy.getClient().then((cli: MatrixClient) => { + cy.all([ + cy.window({ log: false }), + cy.getClient(), + ]).then(([win, cli]) => { + const size = AVATAR_SIZE * win.devicePixelRatio; expect(e.find(".mx_BaseAvatar_image").attr("src")).to.equal( // eslint-disable-next-line no-restricted-properties - cli.mxcUrlToHttp(avatarUrl, AVATAR_SIZE, AVATAR_SIZE, AVATAR_RESIZE_METHOD), + cli.mxcUrlToHttp(avatarUrl, size, size, AVATAR_RESIZE_METHOD), ); }); }; @@ -75,15 +78,17 @@ describe("Timeline", () => { cy.startSynapse("default").then(data => { synapse = data; cy.initTestUser(synapse, OLD_NAME).then(() => - cy.window({ log: false }).then(() => { - cy.createRoom({ name: ROOM_NAME }).then(_room1Id => { - roomId = _room1Id; - }); + cy.createRoom({ name: ROOM_NAME }).then(_room1Id => { + roomId = _room1Id; }), ); }); }); + afterEach(() => { + cy.stopSynapse(synapse); + }); + describe("useOnlyCurrentProfiles", () => { beforeEach(() => { cy.uploadContent(OLD_AVATAR).then((url) => { @@ -95,10 +100,6 @@ describe("Timeline", () => { }); }); - afterEach(() => { - cy.stopSynapse(synapse); - }); - it("should show historical profiles if disabled", () => { cy.setSettingValue("useOnlyCurrentProfiles", null, SettingLevel.ACCOUNT, false); sendEvent(roomId); @@ -146,11 +147,16 @@ describe("Timeline", () => { }); describe("message displaying", () => { + beforeEach(() => { + cy.injectAxe(); + }); + it("should create and configure a room on IRC layout", () => { cy.visit("/#/room/" + roomId); cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); cy.contains(".mx_RoomView_body .mx_GenericEventListSummary[data-layout=irc] " + ".mx_GenericEventListSummary_summary", "created and configured the room."); + cy.get(".mx_Spinner").should("not.exist"); cy.percySnapshot("Configured room on IRC layout"); }); @@ -174,10 +180,12 @@ describe("Timeline", () => { .should('have.css', "margin-inline-start", "104px") .should('have.css', "inset-inline-start", "0px"); + cy.get(".mx_Spinner").should("not.exist"); // Exclude timestamp from snapshot const percyCSS = ".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp " + "{ visibility: hidden !important; }"; cy.percySnapshot("Event line with inline start margin on IRC layout", { percyCSS }); + cy.checkA11y(); }); it("should set inline start padding to a hidden event line", () => { diff --git a/cypress/e2e/2-login/login.spec.ts b/cypress/e2e/2-login/login.spec.ts index 85d2866e498b..ea3a5239f098 100644 --- a/cypress/e2e/2-login/login.spec.ts +++ b/cypress/e2e/2-login/login.spec.ts @@ -41,8 +41,11 @@ describe("Login", () => { }); it("logs in with an existing account and lands on the home screen", () => { + cy.injectAxe(); + cy.get("#mx_LoginForm_username", { timeout: 15000 }).should("be.visible"); cy.percySnapshot("Login"); + cy.checkA11y(); cy.get(".mx_ServerPicker_change").click(); cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index 8a22b5cb5562..44dd93b829b7 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -22,6 +22,7 @@ import { performance } from "./performance"; import { synapseDocker } from "./synapsedocker"; import { webserver } from "./webserver"; import { docker } from "./docker"; +import { log } from "./log"; /** * @type {Cypress.PluginConfig} @@ -31,4 +32,5 @@ export default function(on: PluginEvents, config: PluginConfigOptions) { performance(on, config); synapseDocker(on, config); webserver(on, config); + log(on, config); } diff --git a/cypress/plugins/log.ts b/cypress/plugins/log.ts new file mode 100644 index 000000000000..4b16c9b8cdb7 --- /dev/null +++ b/cypress/plugins/log.ts @@ -0,0 +1,35 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import PluginEvents = Cypress.PluginEvents; +import PluginConfigOptions = Cypress.PluginConfigOptions; + +export function log(on: PluginEvents, config: PluginConfigOptions) { + on("task", { + log(message: string) { + console.log(message); + + return null; + }, + table(message: string) { + console.table(message); + + return null; + }, + }); +} diff --git a/cypress/support/axe.ts b/cypress/support/axe.ts new file mode 100644 index 000000000000..1aa7226619d0 --- /dev/null +++ b/cypress/support/axe.ts @@ -0,0 +1,61 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import "cypress-axe"; +import * as axe from "axe-core"; +import { Options } from "cypress-axe"; + +import Chainable = Cypress.Chainable; + +function terminalLog(violations: axe.Result[]): void { + cy.task( + 'log', + `${violations.length} accessibility violation${ + violations.length === 1 ? '' : 's' + } ${violations.length === 1 ? 'was' : 'were'} detected`, + ); + + // pluck specific keys to keep the table readable + const violationData = violations.map(({ id, impact, description, nodes }) => ({ + id, + impact, + description, + nodes: nodes.length, + })); + + cy.task('table', violationData); +} + +Cypress.Commands.overwrite("checkA11y", ( + originalFn: Chainable["checkA11y"], + context?: string | Node | axe.ContextObject | undefined, + options: Options = {}, + violationCallback?: ((violations: axe.Result[]) => void) | undefined, + skipFailures?: boolean, +): void => { + return originalFn(context, { + ...options, + rules: { + // Disable contrast checking for now as we have too many issues with it + 'color-contrast': { + enabled: false, + }, + ...options.rules, + }, + }, violationCallback ?? terminalLog, skipFailures); +}); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index faff9a83637a..8dbcc97753a4 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -36,3 +36,4 @@ import "./iframes"; import "./timeline"; import "./network"; import "./composer"; +import "./axe"; diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index bf92664f35be..2bd6ab5eba90 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -7,7 +7,11 @@ "dom", "dom.iterable" ], - "types": ["cypress", "@percy/cypress"], + "types": [ + "cypress", + "cypress-axe", + "@percy/cypress" + ], "resolveJsonModule": true, "esModuleInterop": true, "moduleResolution": "node", diff --git a/package.json b/package.json index 0ac7006add5a..3415cb69aad8 100644 --- a/package.json +++ b/package.json @@ -167,10 +167,12 @@ "@typescript-eslint/parser": "^5.6.0", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", "allchange": "^1.0.6", + "axe-core": "^4.4.3", "babel-jest": "^26.6.3", "blob-polyfill": "^6.0.20211015", "chokidar": "^3.5.1", "cypress": "^10.3.0", + "cypress-axe": "^1.0.0", "cypress-real-events": "^1.7.1", "enzyme": "^3.11.0", "enzyme-to-json": "^3.6.2", diff --git a/res/css/views/auth/_AuthBody.pcss b/res/css/views/auth/_AuthBody.pcss index cdccfe157b9d..bd139a2eacbe 100644 --- a/res/css/views/auth/_AuthBody.pcss +++ b/res/css/views/auth/_AuthBody.pcss @@ -29,20 +29,20 @@ limitations under the License. flex-direction: column; } - h2 { + h1 { font-size: $font-24px; - font-weight: 600; + font-weight: $font-semi-bold; margin-top: 8px; color: $authpage-primary-color; } - h3 { + h2 { font-size: $font-14px; - font-weight: 600; + font-weight: $font-semi-bold; color: $authpage-secondary-color; } - h3.mx_AuthBody_centered { + h2.mx_AuthBody_centered { text-align: center; } diff --git a/res/css/views/dialogs/_ServerPickerDialog.pcss b/res/css/views/dialogs/_ServerPickerDialog.pcss index 4dde7cf8007d..4d246512539b 100644 --- a/res/css/views/dialogs/_ServerPickerDialog.pcss +++ b/res/css/views/dialogs/_ServerPickerDialog.pcss @@ -35,11 +35,11 @@ limitations under the License. } } - > h4 { + > h2 { font-size: $font-15px; font-weight: $font-semi-bold; color: $secondary-content; - margin-left: 8px; + margin: 16px 0 16px 8px; } > a { diff --git a/res/css/views/elements/_ServerPicker.pcss b/res/css/views/elements/_ServerPicker.pcss index ce9f031c503a..f1e5e087684c 100644 --- a/res/css/views/elements/_ServerPicker.pcss +++ b/res/css/views/elements/_ServerPicker.pcss @@ -24,7 +24,7 @@ limitations under the License. font-size: $font-14px; line-height: $font-20px; - > h3 { + > h2 { font-weight: $font-semi-bold; margin: 0 0 20px; grid-column: 1; diff --git a/res/css/views/elements/_UseCaseSelection.pcss b/res/css/views/elements/_UseCaseSelection.pcss index 8f6f3d6e8b8c..3daf15772f36 100644 --- a/res/css/views/elements/_UseCaseSelection.pcss +++ b/res/css/views/elements/_UseCaseSelection.pcss @@ -45,7 +45,7 @@ limitations under the License. text-align: center; } - h4 { + h3 { margin: 0; font-weight: 400; font-size: $font-16px; diff --git a/res/css/views/messages/_DateSeparator.pcss b/res/css/views/messages/_DateSeparator.pcss index a1672c7279bd..4386f9a3edef 100644 --- a/res/css/views/messages/_DateSeparator.pcss +++ b/res/css/views/messages/_DateSeparator.pcss @@ -30,9 +30,12 @@ limitations under the License. border-bottom: 1px solid $menu-selected-color; } -.mx_DateSeparator > div { +.mx_DateSeparator > h2 { margin: 0 25px; flex: 0 0 auto; + font-size: inherit; + font-weight: inherit; + color: inherit; } .mx_DateSeparator_jumpToDateMenu { diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index ab8620a26df3..94f1b9e0eee6 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -382,7 +382,7 @@ export default class LeftPanel extends React.Component { return (
-
- + ); } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index d7f437b437d6..dd9bf887b19c 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -674,7 +674,7 @@ class LoggedInView extends React.Component {
-
+
-
+
diff --git a/src/components/structures/auth/CompleteSecurity.tsx b/src/components/structures/auth/CompleteSecurity.tsx index c187e2d78914..b31e259344ae 100644 --- a/src/components/structures/auth/CompleteSecurity.tsx +++ b/src/components/structures/auth/CompleteSecurity.tsx @@ -100,11 +100,11 @@ export default class CompleteSecurity extends React.Component { return ( -

+

{ icon } { title } { skipButton } -

+
diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index 0766dcd09c7a..83a8e3e56519 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -437,7 +437,7 @@ export default class ForgotPassword extends React.Component { -

{ _t('Set a new password') }

+

{ _t('Set a new password') }

{ resetPasswordJsx }
diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 1a102e97b4d5..c00aa909d228 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -600,10 +600,10 @@ export default class LoginComponent extends React.PureComponent -

+

{ _t('Sign in') } { loader } -

+ { errorTextSection } { serverDeadSection } { // when there is only a single (or 0) providers we show a wide button with `Continue with X` text if (providers.length > 1) { // i18n: ssoButtons is a placeholder to help translators understand context - continueWithSection =

+ continueWithSection =

{ _t("Continue with %(ssoButtons)s", { ssoButtons: "" }).trim() } -

; + ; } // i18n: ssoButtons & usernamePassword are placeholders to help translators understand context @@ -521,7 +521,7 @@ export default class Registration extends React.Component { loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"} fragmentAfterLogin={this.props.fragmentAfterLogin} /> -

+

{ _t( "%(ssoButtons)s Or %(usernamePassword)s", { @@ -529,7 +529,7 @@ export default class Registration extends React.Component { usernamePassword: "", }, ).trim() } -

+ ; } @@ -617,7 +617,7 @@ export default class Registration extends React.Component { } else { // regardless of whether we're the client that started the registration or not, we should // try our credentials anyway - regDoneText =

{ _t( + regDoneText =

{ _t( "Log in to your new account.", {}, { a: (sub) => { }} >{ sub }, }, - ) }

; + ) }; } body =
-

{ _t("Registration Successful") }

+

{ _t("Registration Successful") }

{ regDoneText }
; } else { diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index a8a1f30c0321..64dcdce64532 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -298,7 +298,7 @@ export default class SoftLogout extends React.Component { return <>

{ introText }

{ this.renderSsoForm(null) } -

+

{ _t( "%(ssoButtons)s Or %(usernamePassword)s", { @@ -306,7 +306,7 @@ export default class SoftLogout extends React.Component { usernamePassword: "", }, ).trim() } -

+ { this.renderPasswordForm(null) } ; } @@ -327,16 +327,16 @@ export default class SoftLogout extends React.Component { -

+

{ _t("You're signed out") } -

+ -

{ _t("Sign in") }

+

{ _t("Sign in") }

{ this.renderSignInSection() }
-

{ _t("Clear personal data") }

+

{ _t("Clear personal data") }

{ _t( "Warning: Your personal data (including encryption keys) is still stored " + diff --git a/src/components/structures/auth/header/AuthHeaderDisplay.tsx b/src/components/structures/auth/header/AuthHeaderDisplay.tsx index fd5b65a1ebd0..58ef17dae4d3 100644 --- a/src/components/structures/auth/header/AuthHeaderDisplay.tsx +++ b/src/components/structures/auth/header/AuthHeaderDisplay.tsx @@ -33,7 +33,7 @@ export function AuthHeaderDisplay({ title, icon, serverPicker, children }: Props return ( { current?.icon ?? icon } -

{ current?.title ?? title }

+

{ current?.title ?? title }

{ children } { current?.hideServerPicker !== true && serverPicker } diff --git a/src/components/views/auth/AuthBody.tsx b/src/components/views/auth/AuthBody.tsx index cacab416f64f..654f48dacb14 100644 --- a/src/components/views/auth/AuthBody.tsx +++ b/src/components/views/auth/AuthBody.tsx @@ -22,7 +22,7 @@ interface Props { } export default function AuthBody({ flex, children }: PropsWithChildren) { - return
+ return
{ children } -
; + ; } diff --git a/src/components/views/auth/AuthFooter.tsx b/src/components/views/auth/AuthFooter.tsx index 46e389cb4672..a3435e53f343 100644 --- a/src/components/views/auth/AuthFooter.tsx +++ b/src/components/views/auth/AuthFooter.tsx @@ -23,9 +23,9 @@ import { _t } from '../../../languageHandler'; export default class AuthFooter extends React.Component { public render(): React.ReactNode { return ( - + ); } } diff --git a/src/components/views/auth/AuthHeaderLogo.tsx b/src/components/views/auth/AuthHeaderLogo.tsx index 72a2df7b831c..753c7888ac3f 100644 --- a/src/components/views/auth/AuthHeaderLogo.tsx +++ b/src/components/views/auth/AuthHeaderLogo.tsx @@ -18,8 +18,8 @@ import React from 'react'; export default class AuthHeaderLogo extends React.PureComponent { public render(): React.ReactNode { - return
+ return
; + ; } } diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx index 3f63f2a86cea..f6b4e199dcd5 100644 --- a/src/components/views/auth/PasswordLogin.tsx +++ b/src/components/views/auth/PasswordLogin.tsx @@ -422,7 +422,7 @@ export default class PasswordLogin extends React.PureComponent { -

{ _t("Learn more") }

+

{ _t("Learn more") }

{ _t("About homeservers") } diff --git a/src/components/views/elements/ServerPicker.tsx b/src/components/views/elements/ServerPicker.tsx index 4cc60ebf027d..dbf39be33122 100644 --- a/src/components/views/elements/ServerPicker.tsx +++ b/src/components/views/elements/ServerPicker.tsx @@ -85,8 +85,13 @@ const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange } } return
-

{ title || _t("Homeserver") }

- { !disableCustomUrls ? : null } +

{ title || _t("Homeserver") }

+ { !disableCustomUrls ? ( + ): null } { serverName } diff --git a/src/components/views/elements/Spinner.tsx b/src/components/views/elements/Spinner.tsx index ac3ba51fdd9a..410d1b69cb49 100644 --- a/src/components/views/elements/Spinner.tsx +++ b/src/components/views/elements/Spinner.tsx @@ -39,6 +39,7 @@ export default class Spinner extends React.PureComponent { className="mx_Spinner_icon" style={{ width: w, height: h }} aria-label={_t("Loading...")} + role="progressbar" />
); diff --git a/src/components/views/elements/UseCaseSelection.tsx b/src/components/views/elements/UseCaseSelection.tsx index 5a93acf1c655..eaa0a9d3cf20 100644 --- a/src/components/views/elements/UseCaseSelection.tsx +++ b/src/components/views/elements/UseCaseSelection.tsx @@ -57,7 +57,7 @@ export function UseCaseSelection({ onFinished }: Props) {

{ _t("Who will you chat to the most?") }

-

{ _t("We'll help you get connected.") }

+

{ _t("We'll help you get connected.") }

{ isExpanded={!!this.state.contextMenuPosition} title={_t("Jump to date")} > - +
{ contextMenu } @@ -237,15 +237,15 @@ export default class DateSeparator extends React.Component { if (this.state.jumpToDateEnabled) { dateHeaderContent = this.renderJumpToDateMenu(); } else { - dateHeaderContent = ; + dateHeaderContent = ; } // ARIA treats
s as separators, here we abuse them slightly so manually treat this entire thing as one // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers - return

+ return

{ dateHeaderContent }
-

; +
; } } diff --git a/src/components/views/right_panel/HeaderButtons.tsx b/src/components/views/right_panel/HeaderButtons.tsx index 9af2c2e01cbb..db923fcc0e3d 100644 --- a/src/components/views/right_panel/HeaderButtons.tsx +++ b/src/components/views/right_panel/HeaderButtons.tsx @@ -95,7 +95,7 @@ export default abstract class HeaderButtons

extends React.Component + return

{ this.renderButtons() }
; } diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index fd3f5eed3dc7..b75e81aaed8c 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -760,7 +760,7 @@ export default class BasicMessageEditor extends React.Component const { completionIndex } = this.state; const hasAutocomplete = Boolean(this.state.autoComplete); - let activeDescendant; + let activeDescendant: string; if (hasAutocomplete && completionIndex >= 0) { activeDescendant = generateCompletionDomId(completionIndex); } @@ -784,8 +784,8 @@ export default class BasicMessageEditor extends React.Component aria-multiline="true" aria-autocomplete="list" aria-haspopup="listbox" - aria-expanded={hasAutocomplete} - aria-owns="mx_Autocomplete" + aria-expanded={hasAutocomplete ? true : undefined} + aria-owns={hasAutocomplete ? "mx_Autocomplete" : undefined} aria-activedescendant={activeDescendant} dir="auto" aria-disabled={this.props.disabled} diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 7cd43aded0f8..6dd25b6bca6a 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -219,8 +219,7 @@ const NewRoomIntro = () => { { subText } { subButton } ); - return
- + return
  • { !hasExpectedEncryptionSettings(cli, room) && ( { ) } { body } -
  • ; + ; }; export default NewRoomIntro; diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 81ed22e35bb5..22a0a8043a5b 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -45,6 +45,8 @@ import { NotificationStateEvents } from '../../../stores/notifications/Notificat import RoomContext from "../../../contexts/RoomContext"; import RoomLiveShareWarning from '../beacon/RoomLiveShareWarning'; import { BetaPill } from "../beta/BetaCard"; +import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; export interface ISearchInfo { searchTerm: string; @@ -71,6 +73,7 @@ interface IProps { interface IState { contextMenuPosition?: DOMRect; + rightPanelOpen: boolean; } export default class RoomHeader extends React.Component { @@ -89,23 +92,29 @@ export default class RoomHeader extends React.Component { super(props, context); const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room); notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate); - this.state = {}; + this.state = { + rightPanelOpen: RightPanelStore.instance.isOpen, + }; } public componentDidMount() { const cli = MatrixClientPeg.get(); cli.on(RoomStateEvent.Events, this.onRoomStateEvents); + RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); } public componentWillUnmount() { const cli = MatrixClientPeg.get(); - if (cli) { - cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); - } + cli?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); const notiStore = RoomNotificationStateStore.instance.getRoomState(this.props.room); notiStore.removeListener(NotificationStateEvents.Update, this.onNotificationUpdate); + RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); } + private onRightPanelStoreUpdate = () => { + this.setState({ rightPanelOpen: RightPanelStore.instance.isOpen }); + }; + private onRoomStateEvents = (event: MatrixEvent) => { if (!this.props.room || event.getRoomId() !== this.props.room.roomId) { return; @@ -230,7 +239,9 @@ export default class RoomHeader extends React.Component { const roomName = { (name) => { const roomName = name || oobName; - return
    { roomName }
    ; + return
    + { roomName } +
    ; } }
    ; @@ -311,8 +322,11 @@ export default class RoomHeader extends React.Component { ) : null; return ( -
    -
    +
    +
    { roomAvatar }
    { e2eIcon }
    { name } @@ -322,7 +336,7 @@ export default class RoomHeader extends React.Component { { buttons }
    -
    + ); } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 00017859015d..93d013b79ae8 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2408,6 +2408,7 @@ "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.": "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.", "Join millions for free on the largest public server": "Join millions for free on the largest public server", "Homeserver": "Homeserver", + "Help": "Help", "Choose a locale": "Choose a locale", "Continue with %(provider)s": "Continue with %(provider)s", "Sign in with single sign-on": "Sign in with single sign-on", diff --git a/test/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap b/test/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap index d848ae092f0b..dec7c8c9ee26 100644 --- a/test/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap @@ -6,7 +6,7 @@ exports[`DateSeparator renders the date separator correctly 1`] = ` roomId="!unused:example.org" ts={1639728540000} > -

    - +


    - +
    `; @@ -33,7 +33,7 @@ exports[`DateSeparator when feature_jump_to_date is enabled renders the date sep roomId="!unused:example.org" ts={1639728540000} > -

    - +

    @@ -103,6 +103,6 @@ exports[`DateSeparator when feature_jump_to_date is enabled renders the date sep
    - +
    `; diff --git a/test/components/views/settings/__snapshots__/FontScalingPanel-test.tsx.snap b/test/components/views/settings/__snapshots__/FontScalingPanel-test.tsx.snap index ebf752952789..0859be41f773 100644 --- a/test/components/views/settings/__snapshots__/FontScalingPanel-test.tsx.snap +++ b/test/components/views/settings/__snapshots__/FontScalingPanel-test.tsx.snap @@ -31,6 +31,7 @@ exports[`FontScalingPanel renders the font scaling UI 1`] = `