diff --git a/cypress/e2e/read-receipts/read-receipts.spec.ts b/cypress/e2e/read-receipts/read-receipts.spec.ts new file mode 100644 index 0000000000..da84b0060f --- /dev/null +++ b/cypress/e2e/read-receipts/read-receipts.spec.ts @@ -0,0 +1,133 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import type { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; +import type { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; + +describe("Read receipts", () => { + const userName = "Mae"; + const botName = "Other User"; + const selectedRoomName = "Selected Room"; + const otherRoomName = "Other Room"; + + let homeserver: HomeserverInstance; + let otherRoomId: string; + let selectedRoomId: string; + let bot: MatrixClient | undefined; + + const botSendMessage = (): Cypress.Chainable => { + return cy.botSendMessage(bot, otherRoomId, "Message"); + }; + + const fakeEventFromSent = (eventResponse: ISendEventResponse): MatrixEvent => { + return { + getRoomId: () => otherRoomId, + getId: () => eventResponse.event_id, + threadRootId: undefined, + getTs: () => 1, + } as any as MatrixEvent; + }; + + beforeEach(() => { + /* + * Create 2 rooms: + * + * - Selected room - this one is clicked in the UI + * - Other room - this one contains the bot, which will send events so + * we can check its unread state. + */ + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, userName) + .then(() => { + cy.createRoom({ name: selectedRoomName }).then((createdRoomId) => { + selectedRoomId = createdRoomId; + }); + }) + .then(() => { + cy.createRoom({ name: otherRoomName }).then((createdRoomId) => { + otherRoomId = createdRoomId; + }); + }) + .then(() => { + cy.getBot(homeserver, { displayName: botName }).then((botClient) => { + bot = botClient; + }); + }) + .then(() => { + // Invite the bot to Other room + cy.inviteUser(otherRoomId, bot.getUserId()); + cy.visit("/#/room/" + otherRoomId); + cy.findByText(botName + " joined the room").should("exist"); + + // Then go into Selected room + cy.visit("/#/room/" + selectedRoomId); + }); + }); + }); + + afterEach(() => { + cy.stopHomeserver(homeserver); + }); + + it( + "Considers room read if there's a receipt for main even if an earlier unthreaded receipt exists #24629", + { + // When #24629 exists, the test fails the first time but passes later, so we disable retries + // to be sure we are going to fail if the bug comes back. + // Why does it pass the second time? I wish I knew. (andyb) + retries: 0, + }, + () => { + // Details are in https://github.com/vector-im/element-web/issues/24629 + // This proves we've fixed one of the "stuck unreads" issues. + + // Given we sent 3 events on the main thread + botSendMessage(); + botSendMessage().then((main2) => { + botSendMessage().then((main3) => { + // (So the room starts off unread) + cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist"); + + // When we send a threaded receipt for the last event in main + // And an unthreaded receipt for an earlier event + cy.sendReadReceipt(fakeEventFromSent(main3)); + cy.sendReadReceipt(fakeEventFromSent(main2), "m.read" as any as ReceiptType, true); + + // (So the room has no unreads) + cy.findByLabelText(`${otherRoomName}`).should("exist"); + + // And we persuade the app to persist its state to indexeddb by reloading and waiting + cy.reload(); + cy.findByLabelText(`${selectedRoomName}`).should("exist"); + + // And we reload again, fetching the persisted state FROM indexeddb + cy.reload(); + + // Then the room is read, because the persisted state correctly remembers both + // receipts. (In #24629, the unthreaded receipt overwrote the main thread one, + // meaning that the room still said it had unread messages.) + cy.findByLabelText(`${otherRoomName}`).should("exist"); + cy.findByLabelText(`${otherRoomName} Unread messages.`).should("not.exist"); + }); + }); + }, + ); +}); diff --git a/cypress/support/client.ts b/cypress/support/client.ts index 535669d6be..fca966613e 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -20,7 +20,8 @@ import type { FileType, Upload, UploadOpts } from "matrix-js-sdk/src/http-api"; import type { ICreateRoomOpts, ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { Room } from "matrix-js-sdk/src/models/room"; -import type { IContent } from "matrix-js-sdk/src/models/event"; +import type { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import type { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; import Chainable = Cypress.Chainable; import { UserCredentials } from "./login"; @@ -69,6 +70,13 @@ declare global { eventType: string, content: IContent, ): Chainable; + /** + * @param {MatrixEvent} event + * @param {ReceiptType} receiptType + * @param {boolean} unthreaded + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ + sendReadReceipt(event: MatrixEvent, receiptType?: ReceiptType, unthreaded?: boolean): Chainable<{}>; /** * @param {string} name * @param {module:client.callback} callback Optional. @@ -195,6 +203,15 @@ Cypress.Commands.add( }, ); +Cypress.Commands.add( + "sendReadReceipt", + (event: MatrixEvent, receiptType?: ReceiptType, unthreaded?: boolean): Chainable<{}> => { + return cy.getClient().then(async (cli: MatrixClient) => { + return cli.sendReadReceipt(event, receiptType, unthreaded); + }); + }, +); + Cypress.Commands.add("setDisplayName", (name: string): Chainable<{}> => { return cy.getClient().then(async (cli: MatrixClient) => { return cli.setDisplayName(name);