diff --git a/playwright/e2e/room/room-status-bar.spec.ts b/playwright/e2e/room/room-status-bar.spec.ts new file mode 100644 index 0000000000..249aa6e9d5 --- /dev/null +++ b/playwright/e2e/room/room-status-bar.spec.ts @@ -0,0 +1,174 @@ +/* +Copyright 2025 Element Creations Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../element-web-test"; + +test.describe("Room Status Bar", () => { + test.use({ + displayName: "Jim", + page: async ({ page }, use) => { + // Increase width as these components look horrible at lower + // widths. + await page.setViewportSize({ width: 1400, height: 768 }); + await use(page); + }, + room: async ({ app, user }, use) => { + const roomId = await app.client.createRoom({ + name: "A room", + }); + await app.closeNotificationToast(); + await app.viewRoomById(roomId); + await use({ roomId }); + }, + }); + + test("should show an error when sync stops", { tag: "@screenshot" }, async ({ page, user, app, room, axe }) => { + await page.route("**/_matrix/client/*/sync*", async (route, req) => { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: '{"error": "Test fail", "errcode": "M_UNKNOWN"}', + }); + }); + await app.client.sendMessage(room.roomId, "forcing sync to run"); + const banner = page.getByRole("region", { name: "Room status bar" }); + await expect(banner).toBeVisible({ timeout: 15000 }); + await expect(banner).toMatchScreenshot("connectivity_lost.png"); + }); + test("should NOT an error when a resource limit is hit", async ({ page, user, app, room, axe, toasts }) => { + await app.viewRoomById(room.roomId); + await page.route("**/_matrix/client/*/sync*", async (route, req) => { + await route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ + error: "Test fail", + errcode: "M_RESOURCE_LIMIT_EXCEEDED", + limit_type: "monthly_active_user", + admin_contact: "https://example.org", + }), + }); + }); + await app.client.sendMessage(room.roomId, "forcing sync to run"); + // Wait for the MAU warning toast to appear so we know this status bar would have appeared. + await toasts.getToast("Warning", 15000); + await expect(page.getByRole("region", { name: "Room status bar" })).not.toBeVisible(); + }); + test( + "should show an error when the user needs to consent", + { tag: "@screenshot" }, + async ({ page, user, app, room, axe }) => { + await app.viewRoomById(room.roomId); + await page.route("**/_matrix/client/**/send**", async (route) => { + await route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ + error: "Test fail", + errcode: "M_CONSENT_NOT_GIVEN", + consent_uri: "https://example.org", + }), + }); + }); + const composer = app.getComposerField(); + await composer.fill("Hello!"); + await composer.press("Enter"); + await page + .getByRole("dialog", { name: "Terms and Conditions" }) + .getByRole("button", { name: "Dismiss" }) + .click(); + const banner = page.getByRole("region", { name: "Room status bar" }); + await expect(banner).toBeVisible({ timeout: 15000 }); + await expect(banner).toMatchScreenshot("consent.png"); + }, + ); + test.describe("Message fails to send", () => { + test.beforeEach(async ({ page, user, app, room, axe }) => { + await app.viewRoomById(room.roomId); + await page.route("**/_matrix/client/**/send**", async (route) => { + await route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ error: "Test fail", errcode: "M_UNKNOWN" }), + }); + }); + const composer = app.getComposerField(); + await composer.fill("Hello!"); + await composer.press("Enter"); + const banner = page.getByRole("region", { name: "Room status bar" }); + await expect(banner).toBeVisible(); + }); + test( + "should show an error when a message fails to send", + { tag: "@screenshot" }, + async ({ page, user, app, room, axe }) => { + const banner = page.getByRole("region", { name: "Room status bar" }); + await expect(banner).toMatchScreenshot("message_failed.png"); + }, + ); + test("should be able to 'Delete all' messages", async ({ page, user, app, room, axe }) => { + const banner = page.getByRole("region", { name: "Room status bar" }); + await banner.getByRole("button", { name: "Delete all" }).click(); + await expect(banner).not.toBeVisible(); + }); + test("should be able to 'Retry all' messages", async ({ page, user, app, room, axe }) => { + const banner = page.getByRole("region", { name: "Room status bar" }); + await page.unroute("**/_matrix/client/**/send**"); + await banner.getByRole("button", { name: "Retry all" }).click(); + await expect(banner).not.toBeVisible(); + }); + }); + + test.describe("Local rooms", () => { + test.use({ + botCreateOpts: { + displayName: "Alice", + }, + }); + test( + "should show an error when creating a local room fails", + { tag: "@screenshot" }, + async ({ page, app, user, bot }) => { + await page + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); + await page.getByRole("menuitem", { name: "Start chat" }).click(); + + await page.route("**/_matrix/client/*/createRoom*", async (route, req) => { + await route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ + error: "Test fail", + errcode: "M_UNKNOWN", + }), + }); + }); + + const other = page.locator(".mx_InviteDialog_other"); + await other.getByTestId("invite-dialog-input").fill(bot.credentials.userId); + await expect( + other.getByRole("option", { name: "Alice" }).getByText(bot.credentials.userId), + ).toBeVisible(); + await other.getByRole("option", { name: "Alice" }).click(); + await other.getByRole("button", { name: "Go" }).click(); + // Send a message to invite the bots + const composer = app.getComposerField(); + await composer.fill("Hello"); + await composer.press("Enter"); + + const banner = page.getByText("!Some of your messages have"); + await expect(banner).toBeVisible(); + await expect(banner).toMatchScreenshot("local_room_create_failed.png"); + + await page.unroute("**/_matrix/client/*/createRoom*"); + await banner.getByRole("button", { name: "Retry" }).click(); + await expect(banner).not.toBeVisible(); + }, + ); + }); +}); diff --git a/playwright/pages/toasts.ts b/playwright/pages/toasts.ts index cfab354aaf..80ee3c9f26 100644 --- a/playwright/pages/toasts.ts +++ b/playwright/pages/toasts.ts @@ -15,11 +15,12 @@ export class Toasts { * Assert that a toast with the given title exists, and return it * * @param expectedTitle - Expected title of the toast + * @param timeout Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. * @returns the Locator for the matching toast */ - public async getToast(expectedTitle: string): Promise { + public async getToast(expectedTitle: string, timeout?: number): Promise { const toast = this.page.locator(".mx_Toast_toast", { hasText: expectedTitle }).first(); - await expect(toast).toBeVisible(); + await expect(toast).toBeVisible({ timeout }); return toast; } diff --git a/playwright/snapshots/room/room-status-bar.spec.ts/connectivity-lost-linux.png b/playwright/snapshots/room/room-status-bar.spec.ts/connectivity-lost-linux.png new file mode 100644 index 0000000000..106f16403c Binary files /dev/null and b/playwright/snapshots/room/room-status-bar.spec.ts/connectivity-lost-linux.png differ diff --git a/playwright/snapshots/room/room-status-bar.spec.ts/consent-linux.png b/playwright/snapshots/room/room-status-bar.spec.ts/consent-linux.png new file mode 100644 index 0000000000..13aa6a4833 Binary files /dev/null and b/playwright/snapshots/room/room-status-bar.spec.ts/consent-linux.png differ diff --git a/playwright/snapshots/room/room-status-bar.spec.ts/local-room-create-failed-linux.png b/playwright/snapshots/room/room-status-bar.spec.ts/local-room-create-failed-linux.png new file mode 100644 index 0000000000..a8fe32646e Binary files /dev/null and b/playwright/snapshots/room/room-status-bar.spec.ts/local-room-create-failed-linux.png differ diff --git a/playwright/snapshots/room/room-status-bar.spec.ts/message-failed-linux.png b/playwright/snapshots/room/room-status-bar.spec.ts/message-failed-linux.png new file mode 100644 index 0000000000..cccc5fb675 Binary files /dev/null and b/playwright/snapshots/room/room-status-bar.spec.ts/message-failed-linux.png differ diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 5249f4ddd2..70258e143c 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -359,14 +359,15 @@ class LoggedInView extends React.Component { const newErrCode = (data?.error as MatrixError)?.errcode; if (syncState === oldSyncState && oldErrCode === newErrCode) return; + const syncErrorData = syncState === SyncState.Error ? data : undefined; this.setState({ - syncErrorData: syncState === SyncState.Error ? data : undefined, + syncErrorData, }); if (oldSyncState === SyncState.Prepared && syncState === SyncState.Syncing) { this.updateServerNoticeEvents(); } else { - this.calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent); + this.calculateServerLimitToast(syncErrorData, this.state.usageLimitEventContent); } }; @@ -391,7 +392,7 @@ class LoggedInView extends React.Component { // usageLimitDismissed is true when the user has explicitly hidden the toast // and it will be reset to false if a *new* usage alert comes in. - if (usageLimitEventContent && this.state.usageLimitDismissed) { + if (usageLimitEventContent && !this.state.usageLimitDismissed) { showServerLimitToast( usageLimitEventContent.limit_type, this.onUsageLimitDismissed, diff --git a/test/unit-tests/components/structures/LoggedInView-test.tsx b/test/unit-tests/components/structures/LoggedInView-test.tsx index 5b59c18bc1..de4d0a2d58 100644 --- a/test/unit-tests/components/structures/LoggedInView-test.tsx +++ b/test/unit-tests/components/structures/LoggedInView-test.tsx @@ -17,6 +17,8 @@ import { PushRuleKind, ProfileKeyTimezone, ProfileKeyMSC4175Timezone, + SyncState, + MatrixError, } from "matrix-js-sdk/src/matrix"; import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler"; import { logger } from "matrix-js-sdk/src/logger"; @@ -35,6 +37,7 @@ import { SettingLevel } from "../../../../src/settings/SettingLevel"; import { Action } from "../../../../src/dispatcher/actions"; import Modal from "../../../../src/Modal"; import { SETTINGS } from "../../../../src/settings/Settings"; +import ToastStore from "../../../../src/stores/ToastStore"; // Create a mock resizer instance that can be shared across tests const mockResizerInstance = { @@ -505,6 +508,29 @@ describe("", () => { }); }); + describe("resource limit exceeded errors", () => { + it("pops a toast when M_RESOURCE_LIMIT_EXCEEDED is seen down sync", async () => { + const addOrReplaceToast = jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast"); + const dismissToast = jest.spyOn(ToastStore.sharedInstance(), "dismissToast"); + getComponent(); + mockClient.emit(ClientEvent.Sync, SyncState.Error, null, { + error: new MatrixError({ + errcode: "M_RESOURCE_LIMIT_EXCEEDED", + limit_type: "hs_disabled", + admin_contact: "admin@example.org", + }), + }); + expect(addOrReplaceToast).toHaveBeenCalledWith( + expect.objectContaining({ + key: "serverlimit", + title: "Warning", + }), + ); + mockClient.emit(ClientEvent.Sync, SyncState.Prepared, null, undefined); + expect(dismissToast).toHaveBeenCalledWith("serverlimit"); + }); + }); + describe("resizer preferences", () => { let mockResize: jest.Mock; let mockForHandleWithId: jest.Mock;