From 585aa75525b0d670afe53039fa5c032fc15b3a12 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 10 Jan 2025 09:58:07 +0000 Subject: [PATCH 1/3] Remove ts-prune as it has been archived over a year ago (#28954) * Remove ts-prune as it has been archived over a year ago knip replaces it has better configuration Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update knip config Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .github/workflows/static_analysis.yaml | 6 ------ knip.ts | 13 ++++--------- package.json | 2 -- src/Lifecycle.ts | 1 - src/utils/arrays.ts | 2 -- 5 files changed, 4 insertions(+), 20 deletions(-) diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index b7c02c3f2e9..ee731b3ac38 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -132,9 +132,3 @@ jobs: - name: Run linter run: "yarn run lint:knip" - - - name: Install Deps - run: "scripts/layered.sh" - - - name: Dead Code Analysis - run: "yarn run analyse:unused-exports" diff --git a/knip.ts b/knip.ts index 247f9d97894..17ad531332a 100644 --- a/knip.ts +++ b/knip.ts @@ -10,13 +10,13 @@ export default { "playwright/**", "test/**", "res/decoder-ring/**", - ], - project: ["**/*.{js,ts,jsx,tsx}"], - ignore: [ - "docs/**", "res/jitsi_external_api.min.js", + "docs/**", // Used by jest "__mocks__/maplibre-gl.js", + ], + project: ["**/*.{js,ts,jsx,tsx}"], + ignore: [ // Keep for now "src/hooks/useLocalStorageState.ts", "src/components/views/elements/InfoTooltip.tsx", @@ -37,13 +37,8 @@ export default { // False positive "sw.js", // Used by webpack - "buffer", "process", "util", - // Used by workflows - "ts-prune", - // Required due to bug in bloom-filters https://github.com/Callidon/bloom-filters/issues/75 - "@types/seedrandom", ], ignoreBinaries: [ // Used in scripts & workflows diff --git a/package.json b/package.json index 7cda6fa5ace..2b65fa0834f 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,6 @@ "test:playwright:screenshots:build": "docker build playwright -t element-web-playwright", "test:playwright:screenshots:run": "docker run --rm --network host -e BASE_URL -e CI -v $(pwd):/work/ -v $(node -e 'console.log(require(`path`).dirname(require.resolve(`matrix-js-sdk/package.json`)))'):/work/node_modules/matrix-js-sdk -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it element-web-playwright --grep @screenshot --project=Chrome", "coverage": "yarn test --coverage", - "analyse:unused-exports": "ts-node ./scripts/analyse_unused_exports.ts", "analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp", "update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js" }, @@ -287,7 +286,6 @@ "terser-webpack-plugin": "^5.3.9", "testcontainers": "^10.16.0", "ts-node": "^10.9.1", - "ts-prune": "^0.10.3", "typescript": "5.7.2", "util": "^0.12.5", "web-streams-polyfill": "^4.0.0", diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 2f6c460afed..cdb7d391151 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -110,7 +110,6 @@ dis.register((payload) => { let sessionLockStolen = false; // this is exposed solely for unit tests. -// ts-prune-ignore-next export function setSessionLockNotStolen(): void { sessionLockStolen = false; } diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index 25548ef78f0..b54e0949f21 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -49,7 +49,6 @@ export function arrayFastResample(input: number[], points: number): number[] { * @param {number} points The number of samples to end up with. * @returns {number[]} The resampled array. */ -// ts-prune-ignore-next export function arraySmoothingResample(input: number[], points: number): number[] { if (input.length === points) return input; // short-circuit a complicated call @@ -92,7 +91,6 @@ export function arraySmoothingResample(input: number[], points: number): number[ * @param {number} newMax The maximum value to scale to. * @returns {number[]} The rescaled array. */ -// ts-prune-ignore-next export function arrayRescale(input: number[], newMin: number, newMax: number): number[] { const min: number = Math.min(...input); const max: number = Math.max(...input); From f99d7ce2bb45db5ade92d55318dad6819b9c9212 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 10 Jan 2025 10:44:10 +0000 Subject: [PATCH 2/3] React to MatrixEvent sender/target being updated for rendering state events (#28947) * React to MatrixEvent sender/target sentinels being updated for rendering state events Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * React to sentinel changes in EventListSummary Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../views/elements/EventListSummary.tsx | 178 ++++++++++++------ .../views/messages/TextualEvent.tsx | 15 +- src/hooks/usePinnedEvents.ts | 2 +- src/utils/exportUtils/Exporter.ts | 7 +- .../PinnedMessagesCard-test.tsx.snap | 4 + 5 files changed, 143 insertions(+), 63 deletions(-) diff --git a/src/components/views/elements/EventListSummary.tsx b/src/components/views/elements/EventListSummary.tsx index c052a83f661..79d89aed0d0 100644 --- a/src/components/views/elements/EventListSummary.tsx +++ b/src/components/views/elements/EventListSummary.tsx @@ -9,8 +9,9 @@ Please see LICENSE files in the repository root for full details. */ import React, { ComponentProps, ReactNode } from "react"; -import { MatrixEvent, RoomMember, EventType } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixEvent, MatrixEventEvent, RoomMember } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; +import { throttle } from "lodash"; import { _t } from "../../../languageHandler"; import { formatList } from "../../../utils/FormattingUtils"; @@ -22,6 +23,8 @@ import { Layout } from "../../../settings/enums/Layout"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import AccessibleButton from "./AccessibleButton"; import RoomContext from "../../../contexts/RoomContext"; +import { arrayHasDiff } from "../../../utils/arrays.ts"; +import { objectHasDiff } from "../../../utils/objects.ts"; const onPinnedMessagesClick = (): void => { RightPanelStore.instance.setCard({ phase: RightPanelPhases.PinnedMessages }, false); @@ -69,9 +72,14 @@ enum TransitionType { const SEP = ","; -export default class EventListSummary extends React.Component< - IProps & Required> -> { +type Props = IProps & Required>; + +interface State { + userEvents: Record; + summaryMembers: RoomMember[]; +} + +export default class EventListSummary extends React.Component { public static contextType = RoomContext; declare public context: React.ContextType; @@ -82,15 +90,122 @@ export default class EventListSummary extends React.Component< layout: Layout.Group, }; - public shouldComponentUpdate(nextProps: IProps): boolean { + public constructor(props: Props) { + super(props); + + this.state = this.generateState(); + } + + private generateState(): State { + const eventsToRender = this.props.events; + + // Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created, + // so this works perfectly for us to match event order whilst storing the latest Avatar Member + const latestUserAvatarMember = new Map(); + + // Object mapping user IDs to an array of IUserEvents + const userEvents: Record = {}; + eventsToRender.forEach((e, index) => { + const type = e.getType(); + + let userKey = e.getSender()!; + if (e.isState() && type === EventType.RoomThirdPartyInvite) { + userKey = e.getContent().display_name; + } else if (e.isState() && type === EventType.RoomMember) { + userKey = e.getStateKey()!; + } else if (e.isRedacted() && e.getUnsigned()?.redacted_because) { + userKey = e.getUnsigned().redacted_because!.sender; + } + + // Initialise a user's events + if (!userEvents[userKey]) { + userEvents[userKey] = []; + } + + let displayName = userKey; + if (e.isRedacted()) { + const sender = this.context?.room?.getMember(userKey); + if (sender) { + displayName = sender.name; + latestUserAvatarMember.set(userKey, sender); + } + } else if (e.target && TARGET_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { + displayName = e.target.name; + latestUserAvatarMember.set(userKey, e.target); + } else if (e.sender && type !== EventType.RoomThirdPartyInvite) { + displayName = e.sender.name; + latestUserAvatarMember.set(userKey, e.sender); + } + + userEvents[userKey].push({ + mxEvent: e, + displayName, + index: index, + }); + }); + + return { + userEvents, + summaryMembers: Array.from(latestUserAvatarMember.values()), + }; + } + + public componentDidMount(): void { + this.bindSentinelListeners(this.props.events); + } + + public componentDidUpdate(prevProps: Readonly): void { + if (prevProps.events !== this.props.events) { + this.unbindSentinelListeners(prevProps.events); + this.bindSentinelListeners(this.props.events); + this.setState(this.generateState()); + } + } + + public componentWillUnmount(): void { + this.unbindSentinelListeners(this.props.events); + } + + private bindSentinelListeners(events: MatrixEvent[]): void { + for (const event of events) { + event.on(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated); + } + } + + private unbindSentinelListeners(events: MatrixEvent[]): void { + for (const event of events) { + event.on(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated); + } + } + + private onEventSentinelUpdated = throttle( + (): void => { + console.log("@@ SENTINEL UPDATED"); + this.setState(this.generateState()); + }, + 500, + { leading: true, trailing: true }, + ); + + public shouldComponentUpdate(nextProps: Props, nextState: State): boolean { // Update if // - The number of summarised events has changed // - or if the summary is about to toggle to become collapsed // - or if there are fewEvents, meaning the child eventTiles are shown as-is + // - or if the summary members have changed + // - or if the one of IUserEvents within userEvents have changed return ( nextProps.events.length !== this.props.events.length || nextProps.events.length < this.props.threshold || - nextProps.layout !== this.props.layout + nextProps.layout !== this.props.layout || + arrayHasDiff(nextState.summaryMembers, this.state.summaryMembers) || + arrayHasDiff(Object.values(nextState.userEvents), Object.values(this.state.userEvents)) || + Object.keys(nextState.userEvents).length !== Object.keys(this.state.userEvents).length || + Object.keys(nextState.userEvents).some((userId) => + nextState.userEvents[userId].some((event, i) => + objectHasDiff(event, this.state.userEvents[userId]?.[i] ?? {}), + ), + ) ); } @@ -492,54 +607,7 @@ export default class EventListSummary extends React.Component< } public render(): React.ReactNode { - const eventsToRender = this.props.events; - - // Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created, - // so this works perfectly for us to match event order whilst storing the latest Avatar Member - const latestUserAvatarMember = new Map(); - - // Object mapping user IDs to an array of IUserEvents - const userEvents: Record = {}; - eventsToRender.forEach((e, index) => { - const type = e.getType(); - - let userKey = e.getSender()!; - if (e.isState() && type === EventType.RoomThirdPartyInvite) { - userKey = e.getContent().display_name; - } else if (e.isState() && type === EventType.RoomMember) { - userKey = e.getStateKey()!; - } else if (e.isRedacted() && e.getUnsigned()?.redacted_because) { - userKey = e.getUnsigned().redacted_because!.sender; - } - - // Initialise a user's events - if (!userEvents[userKey]) { - userEvents[userKey] = []; - } - - let displayName = userKey; - if (e.isRedacted()) { - const sender = this.context?.room?.getMember(userKey); - if (sender) { - displayName = sender.name; - latestUserAvatarMember.set(userKey, sender); - } - } else if (e.target && TARGET_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { - displayName = e.target.name; - latestUserAvatarMember.set(userKey, e.target); - } else if (e.sender && type !== EventType.RoomThirdPartyInvite) { - displayName = e.sender.name; - latestUserAvatarMember.set(userKey, e.sender); - } - - userEvents[userKey].push({ - mxEvent: e, - displayName, - index: index, - }); - }); - - const aggregate = this.getAggregate(userEvents); + const aggregate = this.getAggregate(this.state.userEvents); // Sort types by order of lowest event index within sequence const orderedTransitionSequences = Object.keys(aggregate.names).sort( @@ -554,7 +622,7 @@ export default class EventListSummary extends React.Component< onToggle={this.props.onToggle} startExpanded={this.props.startExpanded} children={this.props.children} - summaryMembers={[...latestUserAvatarMember.values()]} + summaryMembers={this.state.summaryMembers} layout={this.props.layout} summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} /> diff --git a/src/components/views/messages/TextualEvent.tsx b/src/components/views/messages/TextualEvent.tsx index 8549fc5cab5..41a8fdf1154 100644 --- a/src/components/views/messages/TextualEvent.tsx +++ b/src/components/views/messages/TextualEvent.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix"; import RoomContext from "../../../contexts/RoomContext"; import * as TextForEvent from "../../../TextForEvent"; @@ -21,6 +21,19 @@ export default class TextualEvent extends React.Component { public static contextType = RoomContext; declare public context: React.ContextType; + public componentDidMount(): void { + this.props.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated); + } + public componentWillUnmount(): void { + this.props.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated); + } + + private onEventSentinelUpdated = (): void => { + // XXX: this is crap, but we don't have a better way to force a re-render + // Many TextForEvent handlers render parts of `event.sender` and `event.target` so ensure they are updated + this.forceUpdate(); + }; + public render(): React.ReactNode { const text = TextForEvent.textForEvent( this.props.mxEvent, diff --git a/src/hooks/usePinnedEvents.ts b/src/hooks/usePinnedEvents.ts index bdda4a77013..b065edc83f7 100644 --- a/src/hooks/usePinnedEvents.ts +++ b/src/hooks/usePinnedEvents.ts @@ -154,7 +154,7 @@ async function fetchPinnedEvent(room: Room, pinnedEventId: string, cli: MatrixCl const senderUserId = event.getSender(); if (senderUserId && PinningUtils.isUnpinnable(event)) { // Inject sender information - event.sender = room.getMember(senderUserId); + event.setMetadata(room.currentState, false); // Also inject any edits we've found if (edit) event.makeReplaced(edit); diff --git a/src/utils/exportUtils/Exporter.ts b/src/utils/exportUtils/Exporter.ts index 3852443d1e6..0c549038b47 100644 --- a/src/utils/exportUtils/Exporter.ts +++ b/src/utils/exportUtils/Exporter.ts @@ -110,12 +110,7 @@ export default abstract class Exporter { } protected setEventMetadata(event: MatrixEvent): MatrixEvent { - const roomState = this.room.currentState; - const sender = event.getSender(); - event.sender = (!!sender && roomState?.getSentinelMember(sender)) || null; - if (event.getType() === "m.room.member") { - event.target = roomState?.getSentinelMember(event.getStateKey()!) ?? null; - } + event.setMetadata(this.room.currentState, false); return event; } diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap b/test/unit-tests/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap index 1ffef77d9c0..4a4ac6d6d63 100644 --- a/test/unit-tests/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap +++ b/test/unit-tests/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap @@ -145,6 +145,7 @@ exports[` should show two pinned messages 1`] = ` data-type="round" role="presentation" style="--cpd-avatar-size: 32px;" + title="@alice:example.org" > a @@ -222,6 +223,7 @@ exports[` should show two pinned messages 1`] = ` data-type="round" role="presentation" style="--cpd-avatar-size: 32px;" + title="@alice:example.org" > a @@ -364,6 +366,7 @@ exports[` unpin all should not allow to unpinall 1`] = ` data-type="round" role="presentation" style="--cpd-avatar-size: 32px;" + title="@alice:example.org" > a @@ -441,6 +444,7 @@ exports[` unpin all should not allow to unpinall 1`] = ` data-type="round" role="presentation" style="--cpd-avatar-size: 32px;" + title="@alice:example.org" > a From e14a3b64c386e568b14f1fbce7d3c9f5deb60677 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 13 Jan 2025 09:32:00 +0000 Subject: [PATCH 3/3] Fix flaky playwright tests (#28959) * Fix playwright flaky tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Wipe mailhog between test runs Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- docs/playwright.md | 3 + .../forgot-password/forgot-password.spec.ts | 53 +++++++++++------- playwright/e2e/login/login-consent.spec.ts | 6 ++ playwright/e2e/oidc/index.ts | 2 +- playwright/e2e/oidc/oidc-native.spec.ts | 17 +++++- .../one-to-one-chat/one-to-one-chat.spec.ts | 4 +- .../e2e/share-dialog/share-dialog.spec.ts | 6 +- .../e2e/sliding-sync/sliding-sync.spec.ts | 1 - playwright/services.ts | 27 +++++---- .../share-dialog-event-linux.png | Bin 19318 -> 19219 bytes playwright/testcontainers/mailhog.ts | 30 ++++++++++ 11 files changed, 105 insertions(+), 44 deletions(-) create mode 100644 playwright/testcontainers/mailhog.ts diff --git a/docs/playwright.md b/docs/playwright.md index 315033955b2..2c26b7ab2be 100644 --- a/docs/playwright.md +++ b/docs/playwright.md @@ -77,6 +77,9 @@ test.use({ ``` The appropriate homeserver will be launched by the Playwright worker and reused for all tests which match the worker configuration. +Due to homeservers being reused between tests, please use unique names for any rooms put into the room directory as +they may be visible from other tests, the suggested approach is to use `testInfo.testId` within the name or lodash's uniqueId. +We remove public rooms from the room directory between tests but deleting users doesn't have a homeserver agnostic solution. The logs from testcontainers will be attached to any reports output from Playwright. ## Writing Tests diff --git a/playwright/e2e/forgot-password/forgot-password.spec.ts b/playwright/e2e/forgot-password/forgot-password.spec.ts index 71475e892eb..af4e6def7ed 100644 --- a/playwright/e2e/forgot-password/forgot-password.spec.ts +++ b/playwright/e2e/forgot-password/forgot-password.spec.ts @@ -6,16 +6,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { expect, test } from "../../element-web-test"; +import { expect, test as base } from "../../element-web-test"; import { selectHomeserver } from "../utils"; import { emailHomeserver } from "../../plugins/homeserver/synapse/emailHomeserver.ts"; import { isDendrite } from "../../plugins/homeserver/dendrite"; +import { Credentials } from "../../plugins/homeserver"; -const username = "user1234"; -// this has to be password-like enough to please zxcvbn. Needless to say it's just from pwgen. -const password = "oETo7MPf0o"; const email = "user@nowhere.dummy"; +const test = base.extend<{ credentials: Pick }>({ + // eslint-disable-next-line no-empty-pattern + credentials: async ({}, use, testInfo) => { + await use({ + username: `user_${testInfo.testId}`, + // this has to be password-like enough to please zxcvbn. Needless to say it's just from pwgen. + password: "oETo7MPf0o", + }); + }, +}); + test.use(emailHomeserver); test.use({ config: { @@ -45,31 +54,35 @@ test.describe("Forgot Password", () => { await expect(page.getByRole("main")).toMatchScreenshot("forgot-password.png"); }); - test("renders email verification dialog properly", { tag: "@screenshot" }, async ({ page, homeserver }) => { - const user = await homeserver.registerUser(username, password); + test( + "renders email verification dialog properly", + { tag: "@screenshot" }, + async ({ page, homeserver, credentials }) => { + const user = await homeserver.registerUser(credentials.username, credentials.password); - await homeserver.setThreepid(user.userId, "email", email); + await homeserver.setThreepid(user.userId, "email", email); - await page.goto("/"); + await page.goto("/"); - await page.getByRole("link", { name: "Sign in" }).click(); - await selectHomeserver(page, homeserver.baseUrl); + await page.getByRole("link", { name: "Sign in" }).click(); + await selectHomeserver(page, homeserver.baseUrl); - await page.getByRole("button", { name: "Forgot password?" }).click(); + await page.getByRole("button", { name: "Forgot password?" }).click(); - await page.getByRole("textbox", { name: "Email address" }).fill(email); + await page.getByRole("textbox", { name: "Email address" }).fill(email); - await page.getByRole("button", { name: "Send email" }).click(); + await page.getByRole("button", { name: "Send email" }).click(); - await page.getByRole("button", { name: "Next" }).click(); + await page.getByRole("button", { name: "Next" }).click(); - await page.getByRole("textbox", { name: "New Password", exact: true }).fill(password); - await page.getByRole("textbox", { name: "Confirm new password", exact: true }).fill(password); + await page.getByRole("textbox", { name: "New Password", exact: true }).fill(credentials.password); + await page.getByRole("textbox", { name: "Confirm new password", exact: true }).fill(credentials.password); - await page.getByRole("button", { name: "Reset password" }).click(); + await page.getByRole("button", { name: "Reset password" }).click(); - await expect(page.getByRole("button", { name: "Resend" })).toBeInViewport(); + await expect(page.getByRole("button", { name: "Resend" })).toBeInViewport(); - await expect(page.locator(".mx_Dialog")).toMatchScreenshot("forgot-password-verify-email.png"); - }); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("forgot-password-verify-email.png"); + }, + ); }); diff --git a/playwright/e2e/login/login-consent.spec.ts b/playwright/e2e/login/login-consent.spec.ts index ab70e1d1869..d7d5861a02e 100644 --- a/playwright/e2e/login/login-consent.spec.ts +++ b/playwright/e2e/login/login-consent.spec.ts @@ -77,6 +77,9 @@ async function login(page: Page, homeserver: HomeserverInstance, credentials: Cr await page.getByRole("button", { name: "Sign in" }).click(); } +// This test suite uses the same userId for all tests in the suite +// due to DEVICE_SIGNING_KEYS_BODY being specific to that userId, +// so we restart the Synapse container to make it forget everything. test.use(consentHomeserver); test.use({ config: { @@ -97,6 +100,9 @@ test.use({ ...credentials, displayName, }); + + // Restart the homeserver to wipe its in-memory db so we can reuse the same user ID without cross-signing prompts + await homeserver.restart(); }, }); diff --git a/playwright/e2e/oidc/index.ts b/playwright/e2e/oidc/index.ts index 7e9b03ee6ac..bfd49b496a0 100644 --- a/playwright/e2e/oidc/index.ts +++ b/playwright/e2e/oidc/index.ts @@ -33,7 +33,7 @@ export async function registerAccountMas( expect(messages.items).toHaveLength(1); }).toPass(); expect(messages.items[0].to).toEqual(`${username} <${email}>`); - const [code] = messages.items[0].text.match(/(\d{6})/); + const [, code] = messages.items[0].text.match(/Your verification code to confirm this email address is: (\d{6})/); await page.getByRole("textbox", { name: "6-digit code" }).fill(code); await page.getByRole("button", { name: "Continue" }).click(); diff --git a/playwright/e2e/oidc/oidc-native.spec.ts b/playwright/e2e/oidc/oidc-native.spec.ts index 63cf0a5b59f..a50730ce747 100644 --- a/playwright/e2e/oidc/oidc-native.spec.ts +++ b/playwright/e2e/oidc/oidc-native.spec.ts @@ -17,7 +17,15 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { test.skip(isDendrite, "does not yet support MAS"); test.slow(); // trace recording takes a while here - test("can register the oauth2 client and an account", async ({ context, page, homeserver, mailhogClient, mas }) => { + test("can register the oauth2 client and an account", async ({ + context, + page, + homeserver, + mailhogClient, + mas, + }, testInfo) => { + await page.clock.install(); + const tokenUri = `${mas.baseUrl}/oauth2/token`; const tokenApiPromise = page.waitForRequest( (request) => request.url() === tokenUri && request.postDataJSON()["grant_type"] === "authorization_code", @@ -25,11 +33,14 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { await page.goto("/#/login"); await page.getByRole("button", { name: "Continue" }).click(); - await registerAccountMas(page, mailhogClient, "alice", "alice@email.com", "Pa$sW0rD!"); + + const userId = `alice_${testInfo.testId}`; + await registerAccountMas(page, mailhogClient, userId, "alice@email.com", "Pa$sW0rD!"); // Eventually, we should end up at the home screen. await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 }); - await expect(page.getByRole("heading", { name: "Welcome alice", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: `Welcome ${userId}`, exact: true })).toBeVisible(); + await page.clock.runFor(20000); // run the timer so we see the token request const tokenApiRequest = await tokenApiPromise; expect(tokenApiRequest.postDataJSON()["grant_type"]).toBe("authorization_code"); diff --git a/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts b/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts index deefb305dbc..8a4401f5f23 100644 --- a/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts +++ b/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts @@ -17,8 +17,8 @@ const test = base.extend<{ test.describe("1:1 chat room", () => { test.use({ displayName: "Jeff", - user2: async ({ homeserver }, use) => { - const credentials = await homeserver.registerUser("user1234", "p4s5W0rD", "Timmy"); + user2: async ({ homeserver }, use, testInfo) => { + const credentials = await homeserver.registerUser(`user2_${testInfo.testId}`, "p4s5W0rD", "Timmy"); await use(credentials); }, }); diff --git a/playwright/e2e/share-dialog/share-dialog.spec.ts b/playwright/e2e/share-dialog/share-dialog.spec.ts index d5424a681d6..58574a46ffe 100644 --- a/playwright/e2e/share-dialog/share-dialog.spec.ts +++ b/playwright/e2e/share-dialog/share-dialog.spec.ts @@ -23,7 +23,7 @@ test.describe("Share dialog", () => { const dialog = page.getByRole("dialog", { name: "Share room" }); await expect(dialog.getByText(`https://matrix.to/#/${room.roomId}`)).toBeVisible(); - expect(dialog).toMatchScreenshot("share-dialog-room.png", { + await expect(dialog).toMatchScreenshot("share-dialog-room.png", { // QRCode and url changes at every run mask: [page.locator(".mx_QRCode"), page.locator(".mx_ShareDialog_top > span")], }); @@ -40,7 +40,7 @@ test.describe("Share dialog", () => { const dialog = page.getByRole("dialog", { name: "Share User" }); await expect(dialog.getByText(`https://matrix.to/#/${user.userId}`)).toBeVisible(); - expect(dialog).toMatchScreenshot("share-dialog-user.png", { + await expect(dialog).toMatchScreenshot("share-dialog-user.png", { // QRCode changes at every run mask: [page.locator(".mx_QRCode")], }); @@ -57,7 +57,7 @@ test.describe("Share dialog", () => { const dialog = page.getByRole("dialog", { name: "Share Room Message" }); await expect(dialog.getByRole("checkbox", { name: "Link to selected message" })).toBeChecked(); - expect(dialog).toMatchScreenshot("share-dialog-event.png", { + await expect(dialog).toMatchScreenshot("share-dialog-event.png", { // QRCode and url changes at every run mask: [page.locator(".mx_QRCode"), page.locator(".mx_ShareDialog_top > span")], }); diff --git a/playwright/e2e/sliding-sync/sliding-sync.spec.ts b/playwright/e2e/sliding-sync/sliding-sync.spec.ts index 29a612ccd32..1ab7909a478 100644 --- a/playwright/e2e/sliding-sync/sliding-sync.spec.ts +++ b/playwright/e2e/sliding-sync/sliding-sync.spec.ts @@ -108,7 +108,6 @@ test.describe("Sliding Sync", () => { await page.getByRole("menuitemradio", { name: "A-Z" }).dispatchEvent("click"); await expect(page.locator(".mx_StyledRadioButton_checked").getByText("A-Z")).toBeVisible(); - await page.pause(); await checkOrder(["Apple", "Orange", "Pineapple", "Test Room"], page); }); diff --git a/playwright/services.ts b/playwright/services.ts index b480cbc4054..5e2679953e7 100644 --- a/playwright/services.ts +++ b/playwright/services.ts @@ -7,22 +7,25 @@ Please see LICENSE files in the repository root for full details. import { test as base } from "@playwright/test"; import mailhog from "mailhog"; -import { GenericContainer, Network, StartedNetwork, StartedTestContainer, Wait } from "testcontainers"; +import { Network, StartedNetwork } from "testcontainers"; import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql"; import { SynapseConfigOptions, SynapseContainer } from "./testcontainers/synapse.ts"; import { ContainerLogger } from "./testcontainers/utils.ts"; import { StartedMatrixAuthenticationServiceContainer } from "./testcontainers/mas.ts"; import { HomeserverContainer, StartedHomeserverContainer } from "./testcontainers/HomeserverContainer.ts"; +import { MailhogContainer, StartedMailhogContainer } from "./testcontainers/mailhog.ts"; + +interface TestFixtures { + mailhogClient: mailhog.API; +} export interface Services { logger: ContainerLogger; network: StartedNetwork; postgres: StartedPostgreSqlContainer; - - mailhog: StartedTestContainer; - mailhogClient: mailhog.API; + mailhog: StartedMailhogContainer; synapseConfigOptions: SynapseConfigOptions; _homeserver: HomeserverContainer; @@ -30,7 +33,7 @@ export interface Services { mas?: StartedMatrixAuthenticationServiceContainer; } -export const test = base.extend<{}, Services>({ +export const test = base.extend({ logger: [ // eslint-disable-next-line no-empty-pattern async ({}, use) => { @@ -79,24 +82,20 @@ export const test = base.extend<{}, Services>({ mailhog: [ async ({ logger, network }, use) => { - const container = await new GenericContainer("mailhog/mailhog:latest") + const container = await new MailhogContainer() .withNetwork(network) .withNetworkAliases("mailhog") - .withExposedPorts(8025) .withLogConsumer(logger.getConsumer("mailhog")) - .withWaitStrategy(Wait.forListeningPorts()) .start(); await use(container); await container.stop(); }, { scope: "worker" }, ], - mailhogClient: [ - async ({ mailhog: container }, use) => { - await use(mailhog({ host: container.getHost(), port: container.getMappedPort(8025) })); - }, - { scope: "worker" }, - ], + mailhogClient: async ({ mailhog: container }, use) => { + await use(container.client); + await container.client.deleteAll(); + }, synapseConfigOptions: [{}, { option: true, scope: "worker" }], _homeserver: [ diff --git a/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-event-linux.png b/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-event-linux.png index 541eaa2fa55a29e032a6cfe94d351bfceb572d2d..6a55618c78dcb7451978fe10db216f9acdec0f5a 100644 GIT binary patch literal 19219 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 Tv;(^t00000NkvXXu0mjfV*GsD literal 19318 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 z zWQTt?NnEejE>0f1SzrQ@$IdhOTEip5lC9@=X*eOo<&Wvf%>#lb)(4#%|EF&X*UOy1 z6<8~Ji&Ni%I-ZKtkHHYp05o!`MbiGtA&(W`$Ds<2q2n1}LRXv1n&GbHtYn-C|ZHr_;F-JkzY-I#TMPpK8f7-2E-#;fnd@l!O&4sb&VijYyRRs5(Up!vv13 z3DkGKo0MFe1M{lI(kM2(%eKlvPikprgT0{04sn$7#{)FZ7{NN>v@JlE3=sE1f2edRB{)^1&cDP#* z>oR%X;G|+$Wni1y?CW@NnNUi5S^pTeQ0(Z^8J|Z}#>(jt1!&#rs8G zQW_;m*qAN1$4gf-Xl~--=m>^HBNNSY%qU1QpJ6%9a)O6}%2hpf9a|{e`8~ zct)1Bg##kiQvphmw8qS>=(#W1rk$Z%=5){9iia7m1;^$)L$I5Y(%`0;*7fqGxJe&n z`mt*6RYM@4kYjd#`w_LkY#+{i_Cqbmwv00sNiaKf!Q$dRK%FC!GK3 z1fmS|sP_@xtW*^Ksi8V5$ln&^aLqdDr4nK<^waPk$3i|Avhev^{RTRTQ3;}go)VQ? z^Q^$Su7$O~>EBIlI-bBPEpy3JF* zR#s&BlxuU4|LQOQ@Nf@1S<7ERfN~+=(xrcQr)xd+4UTDe}i6Cz)5B24b-{ z`g;Eg`fJ{`;7XQ{XX8ILxaH+g5?eE;MVeyzZz@}PEx_Se^48_+2yfW^Kspo8buwDx zkowWkg;(vm!l$-Ht1h?3{dQbD1cl@2S95W~gJ74!gzE6$yB|9-|juY3s!3wrRm0d4d&UFy3aHh*>xKlI>2ayH3G{ z_UHO~BP`6lrfx0G(2RRF`j!-b_gf*KgYtw-`zPk}@bm}4bZRqCz$?thZs*?y)`D(d zdiZju#MVej%6kDJrux^Lk9t)P)ozn_y_nOYWj;$lnS@bL6^$5tz;VG3{kPmwMvF@S zHs$l5TE*B*zR}6E=E<+o#aCm;@Olr~|3?6J1d02O&eqJQXNC&idPw9c>QvX38^e3+ zA<@EyJMjCaW(WQxKljlC#orXF6Z1;1)#!;RySO^xtSaHGYG3}fnug(~*J||tK6foe zQ8vuOd9uH!ur5o4#vu;*$P@MxNhdD++5)82?S zD>otUWnbr*fg+*3c)N|7?^;}VWH}KD1%m793Qv2DYCfMg;=cYP{oDi&yz(kt^GCcn zF@hPB`~(hsB2tyrik%&wn>PH<4|`i*>DZ;q#e)WTio4h&+u53VykMzAu&0B-fy(ME z?R_nS%!&8peZs#7IY!?v?94Zd^mNT3-mZ@faT7Z5OEtQhw!W5uN8}}D=8X1wFKT$q zgM;jTAwy5sOb!kd@CXeXLnb5Lyw>j5GKk$NbSf=eOOu}?NBf_YJ-eTY0{RI?O9TQQ zl^ZN|?K~S*>d1f0!*Q9f^CWk>AAGl8^WBLf->OAKKji!p^1yHr)2Wd=I@VNLUYny( z=UphjbhWy=hIN;S_O6}8I64qfZM}id=e2sYZ+q%>Y<|J7VGoV!>6+_}bhXC(t;(3I zHl439-Keuzhz2_I0^J-&dpbn<3I@CI?G4`S^_mT)-zw_=TU4H*y!lf}^>r;vJl;ST z$6znliGJ>n5BvY^-BE9vP!z!NQ(ADgYqcnITV1LR-5Sl3nQZeTEZHYs_OiWfnOW+Z zG$c)nQ4`v93W#grqNx+`g)~)oqw72au95Ggw6hS z&EiXQ*bp_~0H9dNm&!sg=PH$jVj<`KIM_TJHv3WJ%&+E9A!iM4LO09{vHm81ywJ0p z30Eilq1$z9C)hjd`+7-U&iQ`Ho;Ew|cyCjC6+u%U?W zusImmOt5tsaJJi~Dyy2TXz|!?ieo8iBf>BdhGuBm{>z3r7#OBGvc|s~O8c(Mv9V-g zf4N^BV>33B)Euz&8gLV;*6IA|cDsGU92n-n-UXjw=mRsaA1 diff --git a/playwright/testcontainers/mailhog.ts b/playwright/testcontainers/mailhog.ts new file mode 100644 index 00000000000..c3305607d89 --- /dev/null +++ b/playwright/testcontainers/mailhog.ts @@ -0,0 +1,30 @@ +/* +Copyright 2024 New Vector 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 { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; +import mailhog from "mailhog"; + +export class MailhogContainer extends GenericContainer { + constructor() { + super("mailhog/mailhog:latest"); + + this.withExposedPorts(8025).withWaitStrategy(Wait.forListeningPorts()); + } + + public override async start(): Promise { + return new StartedMailhogContainer(await super.start()); + } +} + +export class StartedMailhogContainer extends AbstractStartedContainer { + public readonly client: mailhog.API; + + constructor(container: StartedTestContainer) { + super(container); + this.client = mailhog({ host: container.getHost(), port: container.getMappedPort(8025) }); + } +}