From 8a05976e635b98ec59c70e5aca95b286990f9100 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 22 Apr 2026 14:07:53 -0300 Subject: [PATCH 01/14] docs: e2e performance migration rollout plan Proposal for rolling out the two performance patterns landed in #39691 across the remaining E2E suite. Patterns themselves live in the e2e README; this doc focuses only on how we migrate 150+ files in batches without regressing coverage or review throughput. --- docs/proposals/e2e-performance-migration.md | 142 ++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/proposals/e2e-performance-migration.md diff --git a/docs/proposals/e2e-performance-migration.md b/docs/proposals/e2e-performance-migration.md new file mode 100644 index 0000000000000..23573a844c6e5 --- /dev/null +++ b/docs/proposals/e2e-performance-migration.md @@ -0,0 +1,142 @@ +# E2E performance migration + +Rollout plan for applying the two performance patterns landed in PR #39691 across the rest of the Playwright E2E suite. The patterns themselves (benefits, anti-patterns, template, helper catalog, per-file recipe) live in [`apps/meteor/tests/e2e/README.md`](../../apps/meteor/tests/e2e/README.md#performance-patterns). This document is only about *how we roll them out*. + +Intended audience: contributors and AI agents who pick up migration work. Each phase is written to be actionable without additional context. + +## Current state (baseline) + +Measured on `develop` at the time of writing: + +- 152 spec files under `apps/meteor/tests/e2e/`. +- 43 files already use `test.describe.serial` — primary candidates for Pattern 2 (shared browser context). +- At least 23 files invoke `poHomeChannel.content.sendMessage` inside setup blocks — primary candidates for Pattern 1 (API-driven seeding). +- Reference data point: `quote-messages.spec.ts` went from ~80s to ~10s in CI after both patterns were applied (PR #39691). + +## Success criteria + +A migrated suite is considered done when: + +1. All setup that does not verify behavior goes through REST helpers. +2. If the suite is `.serial`, the browser context is created once in `beforeAll` and torn down in `afterAll`. +3. Median per-test time in CI drops by at least 30% relative to the pre-migration baseline, or the PR body explains why it did not. +4. No coverage regression: every behavior asserted before the migration is still asserted after (explicitly listed when tests are consolidated). + +The project-level target is **p50 < 3s per test** per file. Files above that after migration need a justification in their PR. + +## Phase 0 — consolidate helpers + +Must land before Phase 2 starts. Blocks nothing else. + +Current helpers live in `apps/meteor/tests/e2e/utils/create-target-channel.ts` (mixed responsibilities) and `apps/meteor/tests/e2e/utils/sendMessage.ts` (one function, not re-exported). The new helpers from PR #39691 (`sendMessage`, `createDiscussion`, `createDirectMessageRoom`) sit in `create-target-channel.ts` for historical reasons — they should be moved out. + +Deliverables: + +1. Split `create-target-channel.ts` into one file per concern: + - `channels.ts` (public channels) + - `groups.ts` (private channels and groups) + - `teams.ts` + - `direct-messages.ts` (`createDirectMessage`, `createDirectMessageRoom`) + - `discussions.ts` (`createTargetDiscussion`, `createDiscussion`) + - `messages.ts` (`sendMessage`, `sendTargetChannelMessage`, `sendMessageFromUser`) + - `rooms.ts` (`deleteRoom`, `deleteChannel`, `deleteTeam`) +2. Unify `sendMessage` and `sendMessageFromUser` into a single function with an options bag: `sendMessage(api, roomId, msg, { threadId?, asUser? })`. Remove the duplicated code path. +3. Re-export everything from `utils/index.ts` so specs never need deeper imports. +4. Add helpers the migration is known to need but that are missing today: + - `createThreadReply(api, roomId, parentMsgId, msg)` + - `inviteUsersToRoom(api, roomId, usernames)` + - `setRoomTopic(api, roomId, topic)` (used in ~6 specs via UI today) +5. Update the helper table in `apps/meteor/tests/e2e/README.md` to reflect the new import surface. + +Definition of done: no existing spec broken, no new spec needs to reach past `from './utils'` to seed state. + +## Phase 1 — triage + +One-shot audit that produces the ordered worklist for Phase 2. + +Deliverable: a spreadsheet (or markdown table, committed under `docs/proposals/e2e-migration-triage.md`) with one row per spec file, columns: + +- `path` +- `is_serial` (boolean) +- `ui_setup_hits` — count of `content.sendMessage`, `openLastMessageMenu`, `btnCreateDiscussionModal`, `btnCreateChannel`, `btnCreateDirectMessage` occurrences inside `beforeAll` / `beforeEach` and within setup-only `test.step`s +- `ci_median_ms` — last known median from the Playwright report +- `priority_score` — `ci_median_ms * (is_serial + ui_setup_hits)` +- `opt_out_reason` — non-empty if the spec is one of the "do not migrate" cases (see below) + +Do-not-migrate list (mark `opt_out_reason`): + +- Suites whose subject *is* the setup UI: `create-channel.spec.ts`, `create-direct.spec.ts`, `create-discussion.spec.ts`, `channel-management.spec.ts` (for create flows). +- Auth / session suites: `account-login.spec.ts`, `account-forgetSessionOnWindowClose.spec.ts`, `account-manage-devices.spec.ts`, `enforce-2FA.spec.ts`. +- Federation suite (separate concerns, already covered in `e2e/federation/README.md`). + +The audit can be produced with a script (`scripts/e2e-triage.ts` is a reasonable home) or manually for the first pass. The script is optional — the triage file is not. + +## Phase 2 — migrate in batches + +Rules: + +- **Maximum 5 spec files per PR.** Keeps review tractable and preserves bisect granularity. +- Pick files off the triage list in `priority_score` order. +- PRs are independent — no cross-PR dependencies beyond Phase 0. + +Per-file recipe is in the README ([Migrating an existing suite](../../apps/meteor/tests/e2e/README.md#migrating-an-existing-suite)). Do not duplicate it here. + +Required PR body template for Phase 2: + +```markdown +## E2E migration — batch N + +### Files +- apps/meteor/tests/e2e/.spec.ts +- ... + +### Per-file impact +| File | Tests | p50 before (ms) | p50 after (ms) | Δ | +|------|------:|----------------:|---------------:|--:| +| ... | ... | ... | ... | ... | + +### Patterns applied +- [ ] Pattern 1 — API seeding +- [ ] Pattern 2 — shared browser context + +### Consolidated tests +(list merged tests and confirm each original assertion is still covered — omit if none) + +### Not applied +(reason for any pattern not applied on any file) +``` + +If any file in the batch regresses or stays flat, split that file out into its own PR with a written justification. + +## Phase 3 — guardrails + +Prevents regression after Phase 2 completes. Can land in parallel with Phase 2. + +1. **Doc guardrail** — already in place via README "Anti-patterns to flag in review". Link to it from `.github/pull_request_template.md` under the E2E section. +2. **Lint guardrail (optional)** — a custom ESLint rule or a grep-based CI check that fails when a spec file: + - Uses `poHomeChannel.content.sendMessage` inside `test.beforeEach` or `test.beforeAll`. + - Declares `test.describe.serial` together with `beforeEach(async ({ page }) => { await page.goto(...) })`. + Both are strong signals of missed Pattern 1 / Pattern 2 opportunities. +3. **Timing guardrail** — add a weekly GitHub Action (or extend an existing one) that parses the Playwright report from main and posts a list of spec files with p50 > 3s/test. Recurring offenders become Phase 2 candidates. + +## Picking up the work + +For contributors: + +1. Skim `apps/meteor/tests/e2e/README.md#performance-patterns` and the template. +2. Pick the top unmigrated row from the triage file. +3. Follow the per-file recipe. Open a PR with the template above. +4. One PR = at most 5 files. No exceptions. + +For AI agents: + +- The per-file recipe is deterministic enough to run end-to-end. The two decisions that require judgement are: whether to apply Pattern 2 (check the preconditions listed in the README), and whether to consolidate tests (requires reading assertions carefully). +- Always run the suite before and after, paste both timings in the PR. +- When adding a helper, update the README table in the same PR. +- Do not migrate files in the do-not-migrate list from Phase 1. If you think one should be removed from that list, raise it in the PR body instead of silently migrating. + +## Open questions + +- Should timing guardrails block CI (fail the build) or only report? Lean report-only initially. +- How do we measure p50 reliably across runners of different capacity? Current suggestion is median-of-three on a dedicated runner; needs confirmation from infra. +- Do we want per-feature area ownership for Phase 2 batches, or first-come-first-serve? From 0e7dfede1c9836ff6549113cee58a6a1b1703992 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 23 Apr 2026 11:50:10 -0300 Subject: [PATCH 02/14] test(e2e): consolidate utils helpers for performance migration Phase 0 of the rollout plan in docs/proposals/e2e-performance-migration.md. - Split utils/create-target-channel.ts by concern into channels.ts, groups.ts, teams.ts, direct-messages.ts, discussions.ts, messages.ts and rooms.ts so specs import by topic instead of a catch-all file. - Unify sendMessage and sendMessageFromUser into a single sendMessage(api, roomId, msg, { threadId?, asUser? }) using /chat.sendMessage uniformly. - Add createThreadReply, inviteUsersToRoom and setRoomTopic helpers identified as missing for Phase 2 migrations. - Re-export the new surface from utils/index.ts so specs stay on the './utils' import path. - Update README helper table to match. --- apps/meteor/tests/e2e/README.md | 42 ++--- .../e2e/apps/app-modal-interaction.spec.ts | 2 +- .../e2ee-encryption-decryption.spec.ts | 13 +- apps/meteor/tests/e2e/quote-messages.spec.ts | 2 +- apps/meteor/tests/e2e/utils/channels.ts | 36 ++++ .../tests/e2e/utils/create-target-channel.ts | 169 ------------------ .../meteor/tests/e2e/utils/direct-messages.ts | 18 ++ apps/meteor/tests/e2e/utils/discussions.ts | 35 ++++ apps/meteor/tests/e2e/utils/groups.ts | 20 +++ apps/meteor/tests/e2e/utils/index.ts | 12 +- apps/meteor/tests/e2e/utils/messages.ts | 74 ++++++++ apps/meteor/tests/e2e/utils/rooms.ts | 41 +++++ apps/meteor/tests/e2e/utils/sendMessage.ts | 19 -- apps/meteor/tests/e2e/utils/teams.ts | 23 +++ 14 files changed, 284 insertions(+), 222 deletions(-) create mode 100644 apps/meteor/tests/e2e/utils/channels.ts delete mode 100644 apps/meteor/tests/e2e/utils/create-target-channel.ts create mode 100644 apps/meteor/tests/e2e/utils/direct-messages.ts create mode 100644 apps/meteor/tests/e2e/utils/discussions.ts create mode 100644 apps/meteor/tests/e2e/utils/groups.ts create mode 100644 apps/meteor/tests/e2e/utils/messages.ts create mode 100644 apps/meteor/tests/e2e/utils/rooms.ts delete mode 100644 apps/meteor/tests/e2e/utils/sendMessage.ts create mode 100644 apps/meteor/tests/e2e/utils/teams.ts diff --git a/apps/meteor/tests/e2e/README.md b/apps/meteor/tests/e2e/README.md index 90b96c356b736..0c51d55b95e9e 100644 --- a/apps/meteor/tests/e2e/README.md +++ b/apps/meteor/tests/e2e/README.md @@ -245,26 +245,28 @@ Do **not** apply when: ## API helpers for state seeding -Prefer these helpers in `beforeAll` / `beforeEach` and in setup `test.step`s. All live under `apps/meteor/tests/e2e/utils/`. - -| Intent | Helper | REST endpoint | -| ------------------------------------ | --------------------------------------------------------- | ------------------------- | -| Create public channel | `createTargetChannel(api)` | `/channels.create` | -| Create public channel (full room) | `createTargetChannelAndReturnFullRoom(api)` | `/channels.create` | -| Create private channel | `createTargetPrivateChannel(api)` | `/groups.create` | -| Create private group (full room) | `createTargetGroupAndReturnFullRoom(api)` | `/groups.create` | -| Create team | `createTargetTeam(api)` | `/teams.create` | -| Create discussion (fresh parent) | `createTargetDiscussion(api)` | `/rooms.createDiscussion` | -| Create discussion on existing msg | `createDiscussion(api, parentRoomId, parentMsgId, name)` | `/rooms.createDiscussion` | -| Create DM room (get id back) | `createDirectMessageRoom(api, username)` | `/im.create` | -| Send message to a room | `sendMessage(api, roomId, msg)` | `/chat.sendMessage` | -| Send message inside a thread | `sendMessage(api, roomId, msg, parentMsgId)` | `/chat.sendMessage` | -| Send message as a specific user | `sendMessageFromUser(request, user, rid, msg)` | `/chat.postMessage` | -| Delete channel (by name) | `deleteChannel(api, roomName)` | `/channels.delete` | -| Delete room (by id) | `deleteRoom(api, roomId)` | `/rooms.delete` | -| Delete team | `deleteTeam(api, teamName)` | `/teams.delete` | - -If the helper you need is missing, add it under `utils/` and re-export it from `utils/index.ts` rather than inlining the REST call in the spec. +Prefer these helpers in `beforeAll` / `beforeEach` and in setup `test.step`s. All live under `apps/meteor/tests/e2e/utils/`, split by concern (`channels.ts`, `groups.ts`, `teams.ts`, `direct-messages.ts`, `discussions.ts`, `messages.ts`, `rooms.ts`). Import from `./utils` — never reach into a specific file. + +| Intent | Helper | REST endpoint | +| ------------------------------------ | --------------------------------------------------------------- | ------------------------- | +| Create public channel | `createTargetChannel(api)` | `/channels.create` | +| Create public channel (full room) | `createTargetChannelAndReturnFullRoom(api)` | `/channels.create` | +| Create private channel | `createTargetPrivateChannel(api)` | `/groups.create` | +| Create private group (full room) | `createTargetGroupAndReturnFullRoom(api)` | `/groups.create` | +| Create team | `createTargetTeam(api)` | `/teams.create` | +| Create discussion (fresh parent) | `createTargetDiscussion(api)` | `/rooms.createDiscussion` | +| Create discussion on existing msg | `createDiscussion(api, parentRoomId, parentMsgId, name)` | `/rooms.createDiscussion` | +| Create DM room (get id back) | `createDirectMessageRoom(api, username)` | `/im.create` | +| Send message to a room | `sendMessage(api, roomId, msg)` | `/chat.sendMessage` | +| Send message inside a thread | `createThreadReply(api, roomId, parentMsgId, msg)` | `/chat.sendMessage` | +| Send message as a specific user | `sendMessage(api, roomId, msg, { asUser: user })` | `/chat.sendMessage` | +| Invite users to a room (by username) | `inviteUsersToRoom(api, roomId, usernames)` | `/channels.invite` or `/groups.invite` | +| Set room topic | `setRoomTopic(api, roomId, topic)` | `/rooms.saveRoomSettings` | +| Delete channel (by name) | `deleteChannel(api, roomName)` | `/channels.delete` | +| Delete room (by id) | `deleteRoom(api, roomId)` | `/rooms.delete` | +| Delete team | `deleteTeam(api, teamName)` | `/teams.delete` | + +If the helper you need is missing, add it under the matching file in `utils/` and re-export it from `utils/index.ts` rather than inlining the REST call in the spec. ## Template: optimized `.serial` suite diff --git a/apps/meteor/tests/e2e/apps/app-modal-interaction.spec.ts b/apps/meteor/tests/e2e/apps/app-modal-interaction.spec.ts index 6277010028788..0c28ef8523e54 100644 --- a/apps/meteor/tests/e2e/apps/app-modal-interaction.spec.ts +++ b/apps/meteor/tests/e2e/apps/app-modal-interaction.spec.ts @@ -1,6 +1,6 @@ import { Users } from '../fixtures/userStates'; import { HomeChannel } from '../page-objects'; -import { createTargetChannel } from '../utils/create-target-channel'; +import { createTargetChannel } from '../utils'; import { test, expect } from '../utils/test'; test.use({ storageState: Users.admin.state }); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts index d3d3fe73a149d..825138c25fb9a 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts @@ -8,9 +8,8 @@ import { EncryptedRoomPage } from '../page-objects/encrypted-room'; import { Navbar } from '../page-objects/fragments'; import { FileUploadModal } from '../page-objects/fragments/modals'; import { LoginPage } from '../page-objects/login'; -import { createTargetGroupAndReturnFullRoom, deleteChannel, deleteRoom } from '../utils'; +import { createTargetGroupAndReturnFullRoom, deleteChannel, deleteRoom, sendMessage } from '../utils'; import { preserveSettings } from '../utils/preserveSettings'; -import { sendMessageFromUser } from '../utils/sendMessage'; import { test, expect } from '../utils/test'; const settingsList = ['E2E_Enable', 'E2E_Allow_Unencrypted_Messages']; @@ -165,11 +164,7 @@ test.describe('E2EE Encryption and Decryption - Basic Features', () => { await deleteChannel(api, targetChannelName); }); - test('expect to not crash and not show quote message for a message_link which is not accessible to the user', async ({ - page, - request, - api, - }) => { + test('expect to not crash and not show quote message for a message_link which is not accessible to the user', async ({ page, api }) => { const encryptedRoomPage = new EncryptedRoomPage(page); targetChannelName = faker.string.uuid(); @@ -191,9 +186,9 @@ test.describe('E2EE Encryption and Decryption - Basic Features', () => { targetRoomId = user1Channel._id; // send a message to the private group, which is not accessible to the main user - const sentMessage = (await sendMessageFromUser(request, Users.user2, targetRoomId, 'This is a test message.')).message; + const sentMessageId = await sendMessage(api, targetRoomId, 'This is a test message.', { asUser: Users.user2 }); - const messageLink = `${BASE_URL}/group/${user1Channel.name}?msg=${sentMessage._id}`; + const messageLink = `${BASE_URL}/group/${user1Channel.name}?msg=${sentMessageId}`; await encryptedRoomPage.sendMessage(`This is a message with message link - ${messageLink}`); diff --git a/apps/meteor/tests/e2e/quote-messages.spec.ts b/apps/meteor/tests/e2e/quote-messages.spec.ts index 334dbfcd20da7..192c013f89b3b 100644 --- a/apps/meteor/tests/e2e/quote-messages.spec.ts +++ b/apps/meteor/tests/e2e/quote-messages.spec.ts @@ -187,7 +187,7 @@ test.describe.serial('Quote Messages', () => { await test.step('Setup DM thread and messages via API', async () => { const dmRoomId = await createDirectMessageRoom(api, Users.user2.data.username); const parentMsgId = await sendMessage(api, dmRoomId, messageText); - await sendMessage(api, dmRoomId, threadMessage, parentMsgId); + await sendMessage(api, dmRoomId, threadMessage, { threadId: parentMsgId }); }); await test.step('Open DM thread and quote message', async () => { diff --git a/apps/meteor/tests/e2e/utils/channels.ts b/apps/meteor/tests/e2e/utils/channels.ts new file mode 100644 index 0000000000000..05fc3812125d8 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/channels.ts @@ -0,0 +1,36 @@ +import { faker } from '@faker-js/faker'; +import type { IRoom } from '@rocket.chat/core-typings'; +import type { ChannelsCreateProps } from '@rocket.chat/rest-typings'; + +import type { BaseTest } from './test'; + +export async function createTargetChannel(api: BaseTest['api'], options?: Omit): Promise { + const name = faker.string.uuid(); + await api.post('/channels.create', { name, ...options }); + + return name; +} + +export async function createTargetChannelAndReturnFullRoom( + api: BaseTest['api'], + options?: Omit, +): Promise<{ channel: IRoom }> { + const name = faker.string.uuid(); + return (await api.post('/channels.create', { name, ...options })).json(); +} + +export async function createArchivedChannel(api: BaseTest['api']): Promise { + const { channel } = await createTargetChannelAndReturnFullRoom(api); + + try { + await api.post('/channels.archive', { roomId: channel._id }); + } catch (error) { + throw new Error(`Error archiving the channel: ${error}`); + } + + if (!channel.name) { + throw new Error('Invalid channel was created'); + } + + return channel.name; +} diff --git a/apps/meteor/tests/e2e/utils/create-target-channel.ts b/apps/meteor/tests/e2e/utils/create-target-channel.ts deleted file mode 100644 index 105f25c360af6..0000000000000 --- a/apps/meteor/tests/e2e/utils/create-target-channel.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { faker } from '@faker-js/faker'; -import type { IRoom, IMessage } from '@rocket.chat/core-typings'; -import type { ChannelsCreateProps, GroupsCreateProps } from '@rocket.chat/rest-typings'; - -import type { BaseTest } from './test'; - -/** - * createTargetChannel: - * - Usefull to create a target channel for message related tests - */ -export async function createTargetChannel(api: BaseTest['api'], options?: Omit): Promise { - const name = faker.string.uuid(); - await api.post('/channels.create', { name, ...options }); - - return name; -} - -export async function createTargetChannelAndReturnFullRoom( - api: BaseTest['api'], - options?: Omit, -): Promise<{ channel: IRoom }> { - const name = faker.string.uuid(); - return (await api.post('/channels.create', { name, ...options })).json(); -} - -export async function sendTargetChannelMessage(api: BaseTest['api'], roomName: string, options?: Partial) { - const response = await api.get(`/channels.info?roomName=${roomName}`); - - const { - channel: { _id: rid }, - }: { channel: IRoom } = await response.json(); - - await api.post('/chat.sendMessage', { - message: { - rid, - msg: options?.msg || 'simple message', - ...options, - }, - }); - - return options?.msg || 'simple message'; -} - -export async function deleteChannel(api: BaseTest['api'], roomName: string): Promise { - await api.post('/channels.delete', { roomName }); -} - -export async function deleteRoom(api: BaseTest['api'], roomId: string): Promise { - await api.post('/rooms.delete', { roomId }); -} - -export async function createTargetPrivateChannel(api: BaseTest['api'], options?: Omit): Promise { - const name = faker.string.uuid(); - await api.post('/groups.create', { name, ...options }); - - return name; -} - -export async function createTargetTeam(api: BaseTest['api'], options?: Omit): Promise { - const name = faker.string.uuid(); - await api.post('/teams.create', { name, type: 1, members: ['user2', 'user1'], ...options }); - - return name; -} - -export async function deleteTeam(api: BaseTest['api'], teamName: string): Promise { - await api.post('/teams.delete', { teamName }); -} - -export async function createDirectMessage(api: BaseTest['api']): Promise { - await api.post('/dm.create', { - usernames: 'user1,user2', - }); -} - -export async function createTargetDiscussion(api: BaseTest['api']): Promise> { - const channelName = faker.string.uuid(); - const discussionName = faker.string.uuid(); - - const channelResponse = await api.post('/channels.create', { name: channelName }); - const { channel } = await channelResponse.json(); - const discussionResponse = await api.post('/rooms.createDiscussion', { t_name: discussionName, prid: channel._id }); - const { discussion } = await discussionResponse.json(); - - if (!discussion) { - throw new Error('Discussion not created'); - } - - return discussion; -} - -export async function createChannelWithTeam(api: BaseTest['api']): Promise> { - const channelName = faker.string.uuid(); - const teamName = faker.string.uuid(); - - const teamResponse = await api.post('/teams.create', { name: teamName, type: 1, members: ['user2'] }); - const { team } = await teamResponse.json(); - - await api.post('/channels.create', { name: channelName, members: ['user1'], extraData: { teamId: team._id } }); - - return { channelName, teamName }; -} - -export async function createArchivedChannel(api: BaseTest['api']): Promise { - const { channel } = await createTargetChannelAndReturnFullRoom(api); - - try { - await api.post('/channels.archive', { roomId: channel._id }); - } catch (error) { - throw new Error(`Error archiving the channel: ${error}`); - } - - if (!channel.name) { - throw new Error('Invalid channel was created'); - } - - return channel.name; -} - -export async function createTargetGroupAndReturnFullRoom( - api: BaseTest['api'], - options?: Omit, -): Promise<{ group: IRoom }> { - const name = faker.string.uuid(); - return (await api.post('/groups.create', { name, ...options })).json(); -} - -export async function sendMessage(api: BaseTest['api'], roomId: string, msg: string, threadId?: string): Promise { - const payload: { message: { rid: string; msg: string; tmid?: string } } = { message: { rid: roomId, msg } }; - if (threadId) { - payload.message.tmid = threadId; - } - - const response = await api.post('/chat.sendMessage', payload); - const data: { success?: boolean; message?: { _id: string } } = await response.json(); - - if (!data.success || !data.message?._id) { - throw new Error(`Error sending message: ${JSON.stringify(data)}`); - } - - return data.message._id; -} - -export async function createDiscussion(api: BaseTest['api'], parentRoomId: string, parentMessageId: string, name: string): Promise { - const response = await api.post('/rooms.createDiscussion', { - prid: parentRoomId, - pmid: parentMessageId, - t_name: name, - }); - - const data: { success?: boolean; discussion?: { _id: string } } = await response.json(); - - if (!data.discussion?._id) { - throw new Error(`Error creating discussion: ${JSON.stringify(data)}`); - } - - return data.discussion._id; -} - -export async function createDirectMessageRoom(api: BaseTest['api'], username: string): Promise { - const response = await api.post('/im.create', { username }); - const data: { success?: boolean; room?: { _id: string } } = await response.json(); - - if (!data.room?._id) { - throw new Error(`Error creating direct message room: ${JSON.stringify(data)}`); - } - - return data.room._id; -} diff --git a/apps/meteor/tests/e2e/utils/direct-messages.ts b/apps/meteor/tests/e2e/utils/direct-messages.ts new file mode 100644 index 0000000000000..e57ccf8ce6943 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/direct-messages.ts @@ -0,0 +1,18 @@ +import type { BaseTest } from './test'; + +export async function createDirectMessage(api: BaseTest['api']): Promise { + await api.post('/dm.create', { + usernames: 'user1,user2', + }); +} + +export async function createDirectMessageRoom(api: BaseTest['api'], username: string): Promise { + const response = await api.post('/im.create', { username }); + const data: { success?: boolean; room?: { _id: string } } = await response.json(); + + if (!data.room?._id) { + throw new Error(`Error creating direct message room: ${JSON.stringify(data)}`); + } + + return data.room._id; +} diff --git a/apps/meteor/tests/e2e/utils/discussions.ts b/apps/meteor/tests/e2e/utils/discussions.ts new file mode 100644 index 0000000000000..ef38c3b92fbf4 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/discussions.ts @@ -0,0 +1,35 @@ +import { faker } from '@faker-js/faker'; + +import type { BaseTest } from './test'; + +export async function createTargetDiscussion(api: BaseTest['api']): Promise> { + const channelName = faker.string.uuid(); + const discussionName = faker.string.uuid(); + + const channelResponse = await api.post('/channels.create', { name: channelName }); + const { channel } = await channelResponse.json(); + const discussionResponse = await api.post('/rooms.createDiscussion', { t_name: discussionName, prid: channel._id }); + const { discussion } = await discussionResponse.json(); + + if (!discussion) { + throw new Error('Discussion not created'); + } + + return discussion; +} + +export async function createDiscussion(api: BaseTest['api'], parentRoomId: string, parentMessageId: string, name: string): Promise { + const response = await api.post('/rooms.createDiscussion', { + prid: parentRoomId, + pmid: parentMessageId, + t_name: name, + }); + + const data: { success?: boolean; discussion?: { _id: string } } = await response.json(); + + if (!data.discussion?._id) { + throw new Error(`Error creating discussion: ${JSON.stringify(data)}`); + } + + return data.discussion._id; +} diff --git a/apps/meteor/tests/e2e/utils/groups.ts b/apps/meteor/tests/e2e/utils/groups.ts new file mode 100644 index 0000000000000..2be833abddb0c --- /dev/null +++ b/apps/meteor/tests/e2e/utils/groups.ts @@ -0,0 +1,20 @@ +import { faker } from '@faker-js/faker'; +import type { IRoom } from '@rocket.chat/core-typings'; +import type { GroupsCreateProps } from '@rocket.chat/rest-typings'; + +import type { BaseTest } from './test'; + +export async function createTargetPrivateChannel(api: BaseTest['api'], options?: Omit): Promise { + const name = faker.string.uuid(); + await api.post('/groups.create', { name, ...options }); + + return name; +} + +export async function createTargetGroupAndReturnFullRoom( + api: BaseTest['api'], + options?: Omit, +): Promise<{ group: IRoom }> { + const name = faker.string.uuid(); + return (await api.post('/groups.create', { name, ...options })).json(); +} diff --git a/apps/meteor/tests/e2e/utils/index.ts b/apps/meteor/tests/e2e/utils/index.ts index 40f319591c205..869194fd16309 100644 --- a/apps/meteor/tests/e2e/utils/index.ts +++ b/apps/meteor/tests/e2e/utils/index.ts @@ -1,6 +1,12 @@ -export * from './create-target-channel'; -export * from './setSettingValueById'; -export * from './getSettingValueById'; +export * from './channels'; +export * from './direct-messages'; +export * from './discussions'; export * from './getPermissionRoles'; +export * from './getSettingValueById'; +export * from './groups'; +export * from './messages'; +export * from './rooms'; +export * from './setSettingValueById'; export * from './setUserPreferences'; +export * from './teams'; export * from './updateOwnUserInfo'; diff --git a/apps/meteor/tests/e2e/utils/messages.ts b/apps/meteor/tests/e2e/utils/messages.ts new file mode 100644 index 0000000000000..14511d3d395a3 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/messages.ts @@ -0,0 +1,74 @@ +import { request as baseRequest } from '@playwright/test'; +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; + +import type { BaseTest } from './test'; +import { BASE_API_URL } from '../config/constants'; +import type { IUserState } from '../fixtures/userStates'; + +type SendMessageOptions = { + threadId?: string; + asUser?: IUserState; +}; + +export async function sendMessage(api: BaseTest['api'], roomId: string, msg: string, options?: SendMessageOptions): Promise { + const payload = { + message: { + rid: roomId, + msg, + ...(options?.threadId && { tmid: options.threadId }), + }, + }; + + if (options?.asUser) { + const userContext = await baseRequest.newContext({ + baseURL: BASE_API_URL, + extraHTTPHeaders: { + 'X-Auth-Token': options.asUser.data.loginToken, + 'X-User-Id': options.asUser.data._id, + }, + }); + try { + const response = await userContext.post('/chat.sendMessage', { data: payload }); + const data: { success?: boolean; message?: { _id: string } } = await response.json(); + + if (!data.success || !data.message?._id) { + throw new Error(`Error sending message: ${JSON.stringify(data)}`); + } + + return data.message._id; + } finally { + await userContext.dispose(); + } + } + + const response = await api.post('/chat.sendMessage', payload); + const data: { success?: boolean; message?: { _id: string } } = await response.json(); + + if (!data.success || !data.message?._id) { + throw new Error(`Error sending message: ${JSON.stringify(data)}`); + } + + return data.message._id; +} + +export async function sendTargetChannelMessage(api: BaseTest['api'], roomName: string, options?: Partial) { + const response = await api.get(`/channels.info?roomName=${roomName}`); + + const { + channel: { _id: rid }, + }: { channel: IRoom } = await response.json(); + + await api.post('/chat.sendMessage', { + message: { + rid, + msg: options?.msg || 'simple message', + ...options, + }, + }); + + return options?.msg || 'simple message'; +} + +export async function createThreadReply(api: BaseTest['api'], roomId: string, parentMsgId: string, msg: string): Promise { + return sendMessage(api, roomId, msg, { threadId: parentMsgId }); +} diff --git a/apps/meteor/tests/e2e/utils/rooms.ts b/apps/meteor/tests/e2e/utils/rooms.ts new file mode 100644 index 0000000000000..968a71dba508a --- /dev/null +++ b/apps/meteor/tests/e2e/utils/rooms.ts @@ -0,0 +1,41 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; + +import type { BaseTest } from './test'; + +export async function deleteChannel(api: BaseTest['api'], roomName: string): Promise { + await api.post('/channels.delete', { roomName }); +} + +export async function deleteRoom(api: BaseTest['api'], roomId: string): Promise { + await api.post('/rooms.delete', { roomId }); +} + +export async function deleteTeam(api: BaseTest['api'], teamName: string): Promise { + await api.post('/teams.delete', { teamName }); +} + +export async function inviteUsersToRoom(api: BaseTest['api'], roomId: string, usernames: string[]): Promise { + const infoResponse = await api.get(`/rooms.info?roomId=${roomId}`); + const { room }: { room: IRoom } = await infoResponse.json(); + + const inviteEndpoint = room.t === 'p' ? '/groups.invite' : '/channels.invite'; + + const userIds = await Promise.all( + usernames.map(async (username) => { + const response = await api.get(`/users.info?username=${username}`); + const { user }: { user: Pick } = await response.json(); + + if (!user?._id) { + throw new Error(`Could not resolve username '${username}' to userId`); + } + + return user._id; + }), + ); + + await Promise.all(userIds.map((userId) => api.post(inviteEndpoint, { roomId, userId }))); +} + +export async function setRoomTopic(api: BaseTest['api'], roomId: string, topic: string): Promise { + await api.post('/rooms.saveRoomSettings', { rid: roomId, roomTopic: topic }); +} diff --git a/apps/meteor/tests/e2e/utils/sendMessage.ts b/apps/meteor/tests/e2e/utils/sendMessage.ts deleted file mode 100644 index a17a5158ff006..0000000000000 --- a/apps/meteor/tests/e2e/utils/sendMessage.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { APIRequestContext } from 'playwright-core'; - -import { BASE_API_URL } from '../config/constants'; -import type { IUserState } from '../fixtures/userStates'; - -export const sendMessageFromUser = async (request: APIRequestContext, user: IUserState, rid: string, message: string) => { - return request - .post(`${BASE_API_URL}/chat.postMessage`, { - headers: { - 'X-Auth-Token': user.data.loginToken, - 'X-User-Id': user.data._id, - }, - data: { - roomId: rid, - text: message, - }, - }) - .then((response) => response.json()); -}; diff --git a/apps/meteor/tests/e2e/utils/teams.ts b/apps/meteor/tests/e2e/utils/teams.ts new file mode 100644 index 0000000000000..3679fc1170af4 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/teams.ts @@ -0,0 +1,23 @@ +import { faker } from '@faker-js/faker'; +import type { GroupsCreateProps } from '@rocket.chat/rest-typings'; + +import type { BaseTest } from './test'; + +export async function createTargetTeam(api: BaseTest['api'], options?: Omit): Promise { + const name = faker.string.uuid(); + await api.post('/teams.create', { name, type: 1, members: ['user2', 'user1'], ...options }); + + return name; +} + +export async function createChannelWithTeam(api: BaseTest['api']): Promise> { + const channelName = faker.string.uuid(); + const teamName = faker.string.uuid(); + + const teamResponse = await api.post('/teams.create', { name: teamName, type: 1, members: ['user2'] }); + const { team } = await teamResponse.json(); + + await api.post('/channels.create', { name: channelName, members: ['user1'], extraData: { teamId: team._id } }); + + return { channelName, teamName }; +} From e726efebdafa4c84c0c403d027e97782a6584483 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 23 Apr 2026 14:09:39 -0300 Subject: [PATCH 03/14] docs(e2e): add phase 1 triage worklist and generator script Phase 1 of the rollout plan: the generated triage table is the input for Phase 2 batches. - apps/meteor/tests/e2e/scripts/e2e-triage.mts walks the spec tree, flags serial suites, and counts UI-setup calls inside beforeAll / beforeEach and assertion-free test.step bodies. - docs/proposals/e2e-migration-triage.md: generated worklist (152 specs, 144 migration candidates, 8 opt-outs from the do-not-migrate list). ci_median_ms left blank for a human to fill in from the latest Playwright report; rows are sorted by a stand-in score until that number is populated. - Plan doc now points at both the triage file and the generator. --- apps/meteor/tests/e2e/scripts/e2e-triage.mts | 248 +++++++++++++++++++ docs/proposals/e2e-migration-triage.md | 184 ++++++++++++++ docs/proposals/e2e-performance-migration.md | 4 +- 3 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 apps/meteor/tests/e2e/scripts/e2e-triage.mts create mode 100644 docs/proposals/e2e-migration-triage.md diff --git a/apps/meteor/tests/e2e/scripts/e2e-triage.mts b/apps/meteor/tests/e2e/scripts/e2e-triage.mts new file mode 100644 index 0000000000000..7907c4ba62c74 --- /dev/null +++ b/apps/meteor/tests/e2e/scripts/e2e-triage.mts @@ -0,0 +1,248 @@ +/* + * Produce the Phase 1 triage worklist for the E2E performance migration. + * + * Reads every spec under apps/meteor/tests/e2e/, counts signals that suggest + * the spec is a strong candidate for Patterns 1/2 (API seeding + shared + * context), and writes the result as a markdown table. + * + * Run with: + * node --experimental-strip-types apps/meteor/tests/e2e/scripts/e2e-triage.mts + * + * Output: docs/proposals/e2e-migration-triage.md + * + * ci_median_ms is left blank for a human to fill in from the latest Playwright + * report (priority_score is recomputed when that is known). + */ + +import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const E2E_ROOT = resolve(HERE, '..'); +const REPO_ROOT = resolve(HERE, '..', '..', '..', '..', '..'); +const OUT_PATH = resolve(REPO_ROOT, 'docs', 'proposals', 'e2e-migration-triage.md'); + +const UI_SETUP_PATTERNS = [ + /content\.sendMessage\b/g, + /openLastMessageMenu\b/g, + /btnCreateDiscussionModal\b/g, + /btnCreateChannel\b/g, + /btnCreateDirectMessage\b/g, +] as const; + +const DO_NOT_MIGRATE: Record = { + 'create-channel.spec.ts': 'subject is creation UI', + 'create-direct.spec.ts': 'subject is creation UI', + 'create-discussion.spec.ts': 'subject is creation UI', + 'channel-management.spec.ts': 'subject includes create flows', + 'account-login.spec.ts': 'auth/session suite', + 'account-forgetSessionOnWindowClose.spec.ts': 'auth/session suite', + 'account-manage-devices.spec.ts': 'auth/session suite', + 'enforce-2FA.spec.ts': 'auth/session suite', +}; + +function listSpecFiles(root: string): string[] { + const out: string[] = []; + function walk(dir: string): void { + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === 'scripts' || entry.startsWith('.')) continue; + const full = join(dir, entry); + const info = statSync(full); + if (info.isDirectory()) { + if (entry === 'federation') continue; + walk(full); + } else if (info.isFile() && full.endsWith('.spec.ts')) { + out.push(full); + } + } + } + walk(root); + return out.sort(); +} + +function findBalancedClose(source: string, openIdx: number, openChar: string, closeChar: string): number { + let depth = 0; + let inString: string | null = null; + let inLineComment = false; + let inBlockComment = false; + + for (let i = openIdx; i < source.length; i++) { + const c = source[i]; + const next = source[i + 1]; + + if (inLineComment) { + if (c === '\n') inLineComment = false; + continue; + } + if (inBlockComment) { + if (c === '*' && next === '/') { + inBlockComment = false; + i++; + } + continue; + } + if (inString) { + if (c === '\\') { + i++; + continue; + } + if (c === inString) inString = null; + continue; + } + + if (c === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (c === '/' && next === '*') { + inBlockComment = true; + i++; + continue; + } + if (c === '"' || c === "'" || c === '`') { + inString = c; + continue; + } + if (c === openChar) depth++; + else if (c === closeChar) { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +function countPatterns(chunk: string): number { + let hits = 0; + for (const rx of UI_SETUP_PATTERNS) { + hits += (chunk.match(rx) || []).length; + } + return hits; +} + +function extractBlocks(source: string, anchorRegex: RegExp): { range: string; startIdx: number }[] { + const blocks: { range: string; startIdx: number }[] = []; + let m: RegExpExecArray | null; + // Fresh regex with global flag each call to reset lastIndex. + const rx = new RegExp(anchorRegex.source, 'g'); + while ((m = rx.exec(source)) !== null) { + const openParen = source.indexOf('(', m.index + m[0].length - 1); + if (openParen === -1) continue; + const closeParen = findBalancedClose(source, openParen, '(', ')'); + if (closeParen === -1) continue; + blocks.push({ range: source.slice(openParen + 1, closeParen), startIdx: m.index }); + } + return blocks; +} + +function countUiHitsInSetupScope(source: string): number { + let hits = 0; + + const hookBlocks = extractBlocks(source, /\btest\.(?:beforeAll|beforeEach)\b/); + for (const block of hookBlocks) { + hits += countPatterns(block.range); + } + + const stepBlocks = extractBlocks(source, /\btest\.step\b/); + for (const block of stepBlocks) { + // A test.step is "setup-only" when it makes no assertions. + if (!/\bexpect\s*\(/.test(block.range)) { + hits += countPatterns(block.range); + } + } + + return hits; +} + +type Row = { + path: string; + isSerial: boolean; + uiSetupHits: number; + optOutReason: string; +}; + +function triageRow(fullPath: string): Row { + const source = readFileSync(fullPath, 'utf8'); + const relPath = relative(E2E_ROOT, fullPath); + const basename = relPath.split('/').pop()!; + + const isSerial = /\btest\.describe\.serial\b/.test(source); + const uiSetupHits = countUiHitsInSetupScope(source); + const optOutReason = DO_NOT_MIGRATE[basename] ?? ''; + + return { path: relPath, isSerial, uiSetupHits, optOutReason }; +} + +function computePriorityScore(row: Row, ciMedianMs: number | null): string { + if (row.optOutReason) return '—'; + if (ciMedianMs == null) return '—'; + return String(ciMedianMs * ((row.isSerial ? 1 : 0) + row.uiSetupHits)); +} + +function renderMarkdown(rows: Row[]): string { + const ciKnown = false; + + const migrateRows = rows.filter((r) => !r.optOutReason); + const optOutRows = rows.filter((r) => r.optOutReason); + + const header = `# E2E migration triage + +Generated worklist for the migration plan at [e2e-performance-migration.md](./e2e-performance-migration.md). + +Produced by \`apps/meteor/tests/e2e/scripts/e2e-triage.mts\`. Re-run whenever the spec +surface changes; commit the result. + +\`ci_median_ms\` is intentionally left blank — pull it from the latest Playwright +report on \`main\` and recompute \`priority_score\` (\`ci_median_ms * (is_serial + ui_setup_hits)\`) +before picking a batch. Until then, rows are sorted by \`(is_serial * 5) + ui_setup_hits\` +as a rough stand-in. + +- Total specs: ${rows.length} +- Candidates for Phase 2: ${migrateRows.length} +- Opt-out: ${optOutRows.length} +- Serial suites: ${rows.filter((r) => r.isSerial).length} +- Specs with at least one UI setup hit: ${rows.filter((r) => r.uiSetupHits > 0).length} + +## Phase 2 candidates + +Sorted by the stand-in priority until \`ci_median_ms\` is populated. +`; + + const sorted = migrateRows.slice().sort((a, b) => { + const scoreA = (a.isSerial ? 5 : 0) + a.uiSetupHits; + const scoreB = (b.isSerial ? 5 : 0) + b.uiSetupHits; + if (scoreB !== scoreA) return scoreB - scoreA; + return a.path.localeCompare(b.path); + }); + + const tableHeader = '| path | is_serial | ui_setup_hits | ci_median_ms | priority_score |'; + const tableSep = '| --- | :-: | --: | --: | --: |'; + const tableRows = sorted.map((r) => { + const priority = computePriorityScore(r, ciKnown ? 0 : null); + return `| \`${r.path}\` | ${r.isSerial ? 'yes' : 'no'} | ${r.uiSetupHits} | — | ${priority} |`; + }); + + const optOutHeader = `\n## Opt-out\n\nSpecs the plan explicitly excludes from the migration. See Phase 1 of\n[e2e-performance-migration.md](./e2e-performance-migration.md) for the rationale.\n`; + + const optOutTableHeader = '| path | reason |'; + const optOutTableSep = '| --- | --- |'; + const optOutTableRows = optOutRows + .slice() + .sort((a, b) => a.path.localeCompare(b.path)) + .map((r) => `| \`${r.path}\` | ${r.optOutReason} |`); + + return [header, tableHeader, tableSep, ...tableRows, optOutHeader, optOutTableHeader, optOutTableSep, ...optOutTableRows, ''].join('\n'); +} + +function main(): void { + const files = listSpecFiles(E2E_ROOT); + const rows = files.map(triageRow); + const markdown = renderMarkdown(rows); + writeFileSync(OUT_PATH, markdown); + // eslint-disable-next-line no-console + console.log(`Wrote ${OUT_PATH} (${rows.length} specs)`); +} + +main(); diff --git a/docs/proposals/e2e-migration-triage.md b/docs/proposals/e2e-migration-triage.md new file mode 100644 index 0000000000000..d0c07c5e7c01b --- /dev/null +++ b/docs/proposals/e2e-migration-triage.md @@ -0,0 +1,184 @@ +# E2E migration triage + +Generated worklist for the migration plan at [e2e-performance-migration.md](./e2e-performance-migration.md). + +Produced by `apps/meteor/tests/e2e/scripts/e2e-triage.mts`. Re-run whenever the spec +surface changes; commit the result. + +`ci_median_ms` is intentionally left blank — pull it from the latest Playwright +report on `main` and recompute `priority_score` (`ci_median_ms * (is_serial + ui_setup_hits)`) +before picking a batch. Until then, rows are sorted by `(is_serial * 5) + ui_setup_hits` +as a rough stand-in. + +- Total specs: 152 +- Candidates for Phase 2: 144 +- Opt-out: 8 +- Serial suites: 63 +- Specs with at least one UI setup hit: 9 + +## Phase 2 candidates + +Sorted by the stand-in priority until `ci_median_ms` is populated. + +| path | is_serial | ui_setup_hits | ci_median_ms | priority_score | +| --- | :-: | --: | --: | --: | +| `report-message.spec.ts` | yes | 8 | — | — | +| `messaging.spec.ts` | yes | 2 | — | — | +| `feature-preview.spec.ts` | yes | 1 | — | — | +| `image-gallery.spec.ts` | yes | 1 | — | — | +| `quote-messages.spec.ts` | yes | 1 | — | — | +| `threads.spec.ts` | yes | 1 | — | — | +| `account-profile.spec.ts` | yes | 0 | — | — | +| `account-security.spec.ts` | yes | 0 | — | — | +| `admin-room.spec.ts` | yes | 0 | — | — | +| `admin-users-status-management.spec.ts` | yes | 0 | — | — | +| `administration.spec.ts` | yes | 0 | — | — | +| `apps/apps-contextualbar.spec.ts` | yes | 0 | — | — | +| `apps/apps-modal.spec.ts` | yes | 0 | — | — | +| `apps/private-apps-upload.spec.ts` | yes | 0 | — | — | +| `e2e-encryption/e2ee-passphrase-management.spec.ts` | yes | 0 | — | — | +| `email-inboxes.spec.ts` | yes | 0 | — | — | +| `emojis.spec.ts` | yes | 0 | — | — | +| `file-upload.spec.ts` | yes | 0 | — | — | +| `files-management.spec.ts` | yes | 0 | — | — | +| `global-search.spec.ts` | yes | 0 | — | — | +| `homepage.spec.ts` | yes | 0 | — | — | +| `imports.spec.ts` | yes | 0 | — | — | +| `jump-to-thread-message.spec.ts` | yes | 0 | — | — | +| `mark-unread.spec.ts` | yes | 0 | — | — | +| `message-actions.spec.ts` | yes | 0 | — | — | +| `message-composer.spec.ts` | yes | 0 | — | — | +| `message-mentions.spec.ts` | yes | 0 | — | — | +| `messaging-scroll-to-bottom.spec.ts` | yes | 0 | — | — | +| `notification-sounds.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-agents.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-appearance.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-canned-responses-sidebar.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-changing-room-priority-and-sla.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-contact-conflict-review.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-custom-field-usage.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-departaments-ce.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-livechat-typing-indicator.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-livechat.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-manager.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-monitor-department.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-monitors.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-priorities-sidebar.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-priorities.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-reports.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-triggers.spec.ts` | yes | 0 | — | — | +| `permissions.spec.ts` | yes | 0 | — | — | +| `presence.spec.ts` | yes | 0 | — | — | +| `read-receipts-deactivated-users.spec.ts` | yes | 0 | — | — | +| `read-receipts.spec.ts` | yes | 0 | — | — | +| `retention-policy.spec.ts` | yes | 0 | — | — | +| `search-discussion.spec.ts` | yes | 0 | — | — | +| `settings-assets.spec.ts` | yes | 0 | — | — | +| `settings-int.spec.ts` | yes | 0 | — | — | +| `settings-persistence-on-ui-navigation.spec.ts` | yes | 0 | — | — | +| `sidebar-administration-menu.spec.ts` | yes | 0 | — | — | +| `sidebar.spec.ts` | yes | 0 | — | — | +| `system-messages.spec.ts` | yes | 0 | — | — | +| `team-management.spec.ts` | yes | 0 | — | — | +| `omnichannel/omnichannel-canned-responses-usage.spec.ts` | no | 1 | — | — | +| `omnichannel/omnichannel-livechat-message-bubble-color.spec.ts` | no | 1 | — | — | +| `quote-attachment.spec.ts` | no | 1 | — | — | +| `admin-device-management.spec.ts` | no | 0 | — | — | +| `admin-users-custom-fields.spec.ts` | no | 0 | — | — | +| `admin-users-role-management.spec.ts` | no | 0 | — | — | +| `admin-users.spec.ts` | no | 0 | — | — | +| `administration-settings.spec.ts` | no | 0 | — | — | +| `anonymous-user.spec.ts` | no | 0 | — | — | +| `apps/app-modal-interaction.spec.ts` | no | 0 | — | — | +| `calendar.spec.ts` | no | 0 | — | — | +| `delete-account.spec.ts` | no | 0 | — | — | +| `e2e-encryption/e2ee-encrypted-channels.spec.ts` | no | 0 | — | — | +| `e2e-encryption/e2ee-encryption-decryption.spec.ts` | no | 0 | — | — | +| `e2e-encryption/e2ee-file-encryption.spec.ts` | no | 0 | — | — | +| `e2e-encryption/e2ee-key-reset.spec.ts` | no | 0 | — | — | +| `e2e-encryption/e2ee-legacy-format.spec.ts` | no | 0 | — | — | +| `e2e-encryption/e2ee-pdf-export.spec.ts` | no | 0 | — | — | +| `e2e-encryption/e2ee-server-settings.spec.ts` | no | 0 | — | — | +| `embedded-layout.spec.ts` | no | 0 | — | — | +| `export-messages.spec.ts` | no | 0 | — | — | +| `forgot-password.spec.ts` | no | 0 | — | — | +| `iframe-authentication.spec.ts` | no | 0 | — | — | +| `image-upload.spec.ts` | no | 0 | — | — | +| `login.spec.ts` | no | 0 | — | — | +| `oauth.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-assign-room-tags.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-auto-onhold-chat-closing.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-auto-transfer-unanswered-chat.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-business-hours.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-chat-history.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-chat-transfers.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-close-chat.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-close-inquiry.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-contact-center-chats-filters.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-contact-center-chats.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-contact-center-contacts.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-contact-center-filters.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-contact-info.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-contact-unknown-callout.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-custom-fields.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-departaments.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-enterprise-menus-logout.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-livechat-api.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-livechat-avatar-visibility.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-livechat-background.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-livechat-department.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-livechat-fileupload.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-livechat-hide-expand-chat.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-livechat-logo.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-livechat-queue-management-autoselection.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-livechat-queue-management.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-livechat-tab-communication.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-livechat-watermark.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-livechat-widget.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-manager-role.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-manual-selection-logout.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-manual-selection.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-monitor-role.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-rooms-forward.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-send-pdf-transcript.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-send-transcript.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-sla-policies-sidebar.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-sla-policies.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-tags.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-takeChat.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-transfer-to-another-agents.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-triggers-after-registration.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-triggers-open-by-visitor.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-triggers-setDepartment.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-triggers-time-on-site.spec.ts` | no | 0 | — | — | +| `omnichannel/omnichannel-units.spec.ts` | no | 0 | — | — | +| `preview-public-channel.spec.ts` | no | 0 | — | — | +| `prune-messages.spec.ts` | no | 0 | — | — | +| `register.spec.ts` | no | 0 | — | — | +| `reset-password.spec.ts` | no | 0 | — | — | +| `saml.spec.ts` | no | 0 | — | — | +| `sidebar-menu.spec.ts` | no | 0 | — | — | +| `translations.spec.ts` | no | 0 | — | — | +| `user-card-info-actions-by-member.spec.ts` | no | 0 | — | — | +| `user-card-info-actions-by-room-owner.spec.ts` | no | 0 | — | — | +| `user-required-password-change.spec.ts` | no | 0 | — | — | +| `video-conference-ring.spec.ts` | no | 0 | — | — | +| `video-conference.spec.ts` | no | 0 | — | — | +| `voice-calls-ee.spec.ts` | no | 0 | — | — | + +## Opt-out + +Specs the plan explicitly excludes from the migration. See Phase 1 of +[e2e-performance-migration.md](./e2e-performance-migration.md) for the rationale. + +| path | reason | +| --- | --- | +| `account-forgetSessionOnWindowClose.spec.ts` | auth/session suite | +| `account-login.spec.ts` | auth/session suite | +| `account-manage-devices.spec.ts` | auth/session suite | +| `channel-management.spec.ts` | subject includes create flows | +| `create-channel.spec.ts` | subject is creation UI | +| `create-direct.spec.ts` | subject is creation UI | +| `create-discussion.spec.ts` | subject is creation UI | +| `enforce-2FA.spec.ts` | auth/session suite | diff --git a/docs/proposals/e2e-performance-migration.md b/docs/proposals/e2e-performance-migration.md index 23573a844c6e5..87263cfc0bc21 100644 --- a/docs/proposals/e2e-performance-migration.md +++ b/docs/proposals/e2e-performance-migration.md @@ -54,7 +54,7 @@ Definition of done: no existing spec broken, no new spec needs to reach past `fr One-shot audit that produces the ordered worklist for Phase 2. -Deliverable: a spreadsheet (or markdown table, committed under `docs/proposals/e2e-migration-triage.md`) with one row per spec file, columns: +Deliverable: [`docs/proposals/e2e-migration-triage.md`](./e2e-migration-triage.md) — markdown table with one row per spec file, columns: - `path` - `is_serial` (boolean) @@ -69,7 +69,7 @@ Do-not-migrate list (mark `opt_out_reason`): - Auth / session suites: `account-login.spec.ts`, `account-forgetSessionOnWindowClose.spec.ts`, `account-manage-devices.spec.ts`, `enforce-2FA.spec.ts`. - Federation suite (separate concerns, already covered in `e2e/federation/README.md`). -The audit can be produced with a script (`scripts/e2e-triage.ts` is a reasonable home) or manually for the first pass. The script is optional — the triage file is not. +The audit is produced by [`apps/meteor/tests/e2e/scripts/e2e-triage.mts`](../../apps/meteor/tests/e2e/scripts/e2e-triage.mts). Re-run it (`node --experimental-strip-types apps/meteor/tests/e2e/scripts/e2e-triage.mts`) whenever the spec surface changes or a new Playwright report is landed, and commit the regenerated table. ## Phase 2 — migrate in batches From 9949526a62b0c70ab163132edddd798ac61c3cbf Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 23 Apr 2026 14:24:08 -0300 Subject: [PATCH 04/14] test(e2e): migrate first batch of specs to api-seeded setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 batch 1. Per-file impact pending a Playwright-report rerun; structural changes only. Files in this batch: - report-message.spec.ts — replace 5 "send message as user1" test.steps with sendMessage(api, channelId, msg, { asUser: Users.user1 }) and reuse the admin HomeChannel created in beforeAll. Drops the user1 page.goto('/home') on 4 of 5 tests (only the own-message menu test still needs it). - messaging.spec.ts — seed msg1 / msg2 via API (as user1, so the edit-by-ArrowUp assertion still passes) in the outer beforeAll instead of the first Navigation test. - threads.spec.ts — parent messages for the two outer tests and the "thread message actions" beforeEach now come from sendMessage(api, ...). UI openReplyInThread / sendMessageInThread stays because the tests are about the reply-in-thread flow itself. - image-gallery.spec.ts — image-link seed in the "When sending an image as a link" describe moved to sendMessage(api, ..., { asUser: Users.user1 }) so the unfurling still happens server-side but the client-side type-and-send step is gone. Pattern 2 (shared browser context): not applied. None of these suites meet every precondition: report-message uses two pages, messaging spans multiple serial describes that don't leave the page at a shared home position, threads and image-gallery tests end in different URL/gallery states. Captured for follow-up PRs. Timings: not measured — this environment cannot run Playwright. The triage doc still has ci_median_ms blank; the PR body should be updated with before/after numbers before merge per the Phase 2 template in docs/proposals/e2e-performance-migration.md. --- apps/meteor/tests/e2e/image-gallery.spec.ts | 22 ++- apps/meteor/tests/e2e/messaging.spec.ts | 18 +-- apps/meteor/tests/e2e/report-message.spec.ts | 134 +++++++------------ apps/meteor/tests/e2e/threads.spec.ts | 23 ++-- 4 files changed, 88 insertions(+), 109 deletions(-) diff --git a/apps/meteor/tests/e2e/image-gallery.spec.ts b/apps/meteor/tests/e2e/image-gallery.spec.ts index 9dc2a4368d62f..d2cc62d8704cb 100644 --- a/apps/meteor/tests/e2e/image-gallery.spec.ts +++ b/apps/meteor/tests/e2e/image-gallery.spec.ts @@ -1,13 +1,15 @@ import { createAuxContext } from './fixtures/createAuxContext'; import { Users } from './fixtures/userStates'; import { HomeChannel } from './page-objects'; -import { createTargetChannel, deleteChannel } from './utils'; +import { createTargetChannelAndReturnFullRoom, deleteRoom, sendMessage } from './utils'; import { expect, test } from './utils/test'; test.describe.serial('Image Gallery', async () => { let poHomeChannel: HomeChannel; let targetChannel: string; + let targetChannelId: string; let targetChannelLargeImage: string; + let targetChannelLargeImageId: string; const viewport = { width: 1280, height: 720, @@ -18,8 +20,14 @@ test.describe.serial('Image Gallery', async () => { test.use({ viewport }); test.beforeAll(async ({ api, browser }) => { - targetChannel = await createTargetChannel(api); - targetChannelLargeImage = await createTargetChannel(api); + const [channel, channelLarge] = await Promise.all([ + createTargetChannelAndReturnFullRoom(api), + createTargetChannelAndReturnFullRoom(api), + ]); + targetChannel = channel.channel.name!; + targetChannelId = channel.channel._id; + targetChannelLargeImage = channelLarge.channel.name!; + targetChannelLargeImageId = channelLarge.channel._id; const { page } = await createAuxContext(browser, Users.user1); poHomeChannel = new HomeChannel(page); @@ -32,8 +40,8 @@ test.describe.serial('Image Gallery', async () => { test.afterAll(async ({ api }) => { await poHomeChannel.page.close(); - await deleteChannel(api, targetChannel); - await deleteChannel(api, targetChannelLargeImage); + await deleteRoom(api, targetChannelId); + await deleteRoom(api, targetChannelLargeImageId); }); test.describe('When sending an image as a file', () => { @@ -124,8 +132,8 @@ test.describe.serial('Image Gallery', async () => { test.describe('When sending an image as a link', () => { const imageLink = 'https://raw.githubusercontent.com/RocketChat/Rocket.Chat.Artwork/master/Logos/2020/png/logo-horizontal-red.png'; - test.beforeAll(async () => { - await poHomeChannel.content.sendMessage(imageLink); + test.beforeAll(async ({ api }) => { + await sendMessage(api, targetChannelId, imageLink, { asUser: Users.user1 }); await expect(poHomeChannel.content.lastUserMessage).toContainText(imageLink); diff --git a/apps/meteor/tests/e2e/messaging.spec.ts b/apps/meteor/tests/e2e/messaging.spec.ts index f17720d3ba56b..4438292e3ccd0 100644 --- a/apps/meteor/tests/e2e/messaging.spec.ts +++ b/apps/meteor/tests/e2e/messaging.spec.ts @@ -4,7 +4,7 @@ import type { Page } from '@playwright/test'; import { createAuxContext } from './fixtures/createAuxContext'; import { Users } from './fixtures/userStates'; import { HomeChannel } from './page-objects'; -import { createTargetChannel, deleteChannel } from './utils'; +import { createTargetChannelAndReturnFullRoom, deleteRoom, sendMessage } from './utils'; import { expect, test } from './utils/test'; test.use({ storageState: Users.user1.state }); @@ -12,9 +12,15 @@ test.use({ storageState: Users.user1.state }); test.describe('Messaging', () => { let poHomeChannel: HomeChannel; let targetChannel: string; + let targetChannelId: string; test.beforeAll(async ({ api }) => { - targetChannel = await createTargetChannel(api); + const { channel } = await createTargetChannelAndReturnFullRoom(api); + targetChannel = channel.name!; + targetChannelId = channel._id; + + await sendMessage(api, targetChannelId, 'msg1', { asUser: Users.user1 }); + await sendMessage(api, targetChannelId, 'msg2', { asUser: Users.user1 }); }); test.beforeEach(async ({ page }) => { @@ -23,16 +29,12 @@ test.describe('Messaging', () => { }); test.afterAll(async ({ api }) => { - await deleteChannel(api, targetChannel); + await deleteRoom(api, targetChannelId); }); test.describe.serial('Navigation', () => { test('should navigate on messages using keyboard', async ({ page }) => { - await test.step('open chat and send message', async () => { - await poHomeChannel.navbar.openChat(targetChannel); - await poHomeChannel.content.sendMessage('msg1'); - await poHomeChannel.content.sendMessage('msg2'); - }); + await poHomeChannel.navbar.openChat(targetChannel); await test.step('move focus to the second message', async () => { await page.keyboard.press('Shift+Tab'); diff --git a/apps/meteor/tests/e2e/report-message.spec.ts b/apps/meteor/tests/e2e/report-message.spec.ts index 40e0fdfe8dccf..1608e14007660 100644 --- a/apps/meteor/tests/e2e/report-message.spec.ts +++ b/apps/meteor/tests/e2e/report-message.spec.ts @@ -4,7 +4,7 @@ import type { Page } from '@playwright/test'; import { Users } from './fixtures/userStates'; import { HomeChannel } from './page-objects'; import { ReportMessageModal } from './page-objects/fragments'; -import { createTargetChannel, deleteChannel } from './utils'; +import { createTargetChannelAndReturnFullRoom, deleteRoom, sendMessage } from './utils'; import { test, expect } from './utils/test'; test.use({ storageState: Users.user1.state }); @@ -12,12 +12,17 @@ test.use({ storageState: Users.user1.state }); test.describe.serial('report message', () => { let poHomeChannel: HomeChannel; let targetChannel: string; + let targetChannelId: string; let adminPage: Page; + let adminHomeChannel: HomeChannel; let reportModal: ReportMessageModal; test.beforeAll(async ({ api, browser }) => { - targetChannel = await createTargetChannel(api, { members: ['user1', 'admin'] }); + const { channel } = await createTargetChannelAndReturnFullRoom(api, { members: ['user1', 'admin'] }); + targetChannel = channel.name!; + targetChannelId = channel._id; adminPage = await browser.newPage({ storageState: Users.admin.state }); + adminHomeChannel = new HomeChannel(adminPage); reportModal = new ReportMessageModal(adminPage); }); @@ -26,112 +31,71 @@ test.describe.serial('report message', () => { api.post('/moderation.user.deleteReportedMessages', { userId: 'user1', }), - deleteChannel(api, targetChannel), + deleteRoom(api, targetChannelId), adminPage.close(), ]); }); test.beforeEach(async ({ page }) => { poHomeChannel = new HomeChannel(page); - - await page.goto('/home'); await adminPage.goto('/home'); }); - test('should show report message option in message menu for other users messages', async () => { - await test.step('send message as user1', async () => { - await poHomeChannel.navbar.openChat(targetChannel); - const testMessage = faker.lorem.sentence(); - await poHomeChannel.content.sendMessage(testMessage); - }); - - await test.step('verify report option is visible for the other user', async () => { - const adminHomeChannel = new HomeChannel(adminPage); - await adminHomeChannel.navbar.openChat(targetChannel); - await adminHomeChannel.content.openLastMessageMenu(); - await expect(adminPage.getByRole('menuitem', { name: 'Report' })).toBeVisible(); - }); - }); + test('should show report message option in message menu for other users messages', async ({ api }) => { + await sendMessage(api, targetChannelId, faker.lorem.sentence(), { asUser: Users.user1 }); - test('should not show report message option in message menu for own messages', async ({ page }) => { - await test.step('send message as user1', async () => { - await poHomeChannel.navbar.openChat(targetChannel); - const testMessage = faker.lorem.sentence(); - await poHomeChannel.content.sendMessage(testMessage); - }); - - await test.step('verify report option is not visible for own message', async () => { - await poHomeChannel.content.openLastMessageMenu(); - await expect(page.getByRole('menuitem', { name: 'Report' })).not.toBeVisible(); - }); + await adminHomeChannel.navbar.openChat(targetChannel); + await adminHomeChannel.content.openLastMessageMenu(); + await expect(adminPage.getByRole('menuitem', { name: 'Report' })).toBeVisible(); }); - test('should validate empty report description', async () => { - await test.step('send message as user1', async () => { - await poHomeChannel.navbar.openChat(targetChannel); - const testMessage = faker.lorem.sentence(); - await poHomeChannel.content.sendMessage(testMessage); - }); - - await test.step('try to submit empty report', async () => { - const adminHomeChannel = new HomeChannel(adminPage); - await adminHomeChannel.navbar.openChat(targetChannel); - - await adminHomeChannel.content.openLastMessageMenu(); - await adminPage.getByRole('menuitem', { name: 'Report' }).click(); - await reportModal.submitReport(); - }); + test('should not show report message option in message menu for own messages', async ({ api, page }) => { + await sendMessage(api, targetChannelId, faker.lorem.sentence(), { asUser: Users.user1 }); + + await page.goto('/home'); + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.content.openLastMessageMenu(); + await expect(page.getByRole('menuitem', { name: 'Report' })).not.toBeVisible(); }); - test('should be able to cancel reporting a message', async () => { - await test.step('send message as user1', async () => { - await poHomeChannel.navbar.openChat(targetChannel); - const testMessage = faker.lorem.sentence(); - await poHomeChannel.content.sendMessage(testMessage); - }); - - await test.step('open and cancel report modal', async () => { - const adminHomeChannel = new HomeChannel(adminPage); - await adminHomeChannel.navbar.openChat(targetChannel); - - await adminHomeChannel.content.openLastMessageMenu(); - await adminPage.getByRole('menuitem', { name: 'Report' }).click(); - await reportModal.cancelReport(); - }); + test('should validate empty report description', async ({ api }) => { + await sendMessage(api, targetChannelId, faker.lorem.sentence(), { asUser: Users.user1 }); + + await adminHomeChannel.navbar.openChat(targetChannel); + await adminHomeChannel.content.openLastMessageMenu(); + await adminPage.getByRole('menuitem', { name: 'Report' }).click(); + await reportModal.submitReport(); }); - test('should successfully report a message and verify its appearance in moderation console', async () => { - let testMessage: string; - let reportDescription: string; + test('should be able to cancel reporting a message', async ({ api }) => { + await sendMessage(api, targetChannelId, faker.lorem.sentence(), { asUser: Users.user1 }); - await test.step('send message as user1', async () => { - await poHomeChannel.navbar.openChat(targetChannel); - testMessage = faker.lorem.sentence(); - await poHomeChannel.content.sendMessage(testMessage); - }); + await adminHomeChannel.navbar.openChat(targetChannel); + await adminHomeChannel.content.openLastMessageMenu(); + await adminPage.getByRole('menuitem', { name: 'Report' }).click(); + await reportModal.cancelReport(); + }); - await test.step('report message as the other user', async () => { - reportDescription = faker.lorem.sentence(); + test('should successfully report a message and verify its appearance in moderation console', async ({ api }) => { + const testMessage = faker.lorem.sentence(); + const reportDescription = faker.lorem.sentence(); - const adminHomeChannel = new HomeChannel(adminPage); - await adminHomeChannel.navbar.openChat(targetChannel); + await sendMessage(api, targetChannelId, testMessage, { asUser: Users.user1 }); - await adminHomeChannel.content.openLastMessageMenu(); - await adminPage.getByRole('menuitem', { name: 'Report' }).click(); - await reportModal.submitReport(reportDescription); - }); + await adminHomeChannel.navbar.openChat(targetChannel); + await adminHomeChannel.content.openLastMessageMenu(); + await adminPage.getByRole('menuitem', { name: 'Report' }).click(); + await reportModal.submitReport(reportDescription); - await test.step('verify report in moderation console', async () => { - await adminPage.goto('/admin/moderation/messages'); + await adminPage.goto('/admin/moderation/messages'); - await expect(adminPage.getByRole('tab', { name: 'Reported messages' })).toBeVisible(); - await expect(adminPage.getByRole('link', { name: 'user1' })).toBeVisible(); - await adminPage.getByRole('link', { name: 'user1' }).click(); + await expect(adminPage.getByRole('tab', { name: 'Reported messages' })).toBeVisible(); + await expect(adminPage.getByRole('link', { name: 'user1' })).toBeVisible(); + await adminPage.getByRole('link', { name: 'user1' }).click(); - await expect(adminPage.getByText(testMessage)).toBeVisible(); + await expect(adminPage.getByText(testMessage)).toBeVisible(); - await adminPage.getByRole('button', { name: 'Show reports' }).click(); - await expect(adminPage.getByText(reportDescription)).toBeVisible(); - }); + await adminPage.getByRole('button', { name: 'Show reports' }).click(); + await expect(adminPage.getByText(reportDescription)).toBeVisible(); }); }); diff --git a/apps/meteor/tests/e2e/threads.spec.ts b/apps/meteor/tests/e2e/threads.spec.ts index 88782e0d2b3e1..656b6beeba843 100644 --- a/apps/meteor/tests/e2e/threads.spec.ts +++ b/apps/meteor/tests/e2e/threads.spec.ts @@ -1,14 +1,17 @@ import { Users } from './fixtures/userStates'; import { HomeChannel } from './page-objects'; -import { createTargetChannel, deleteChannel } from './utils'; +import { createTargetChannelAndReturnFullRoom, deleteRoom, sendMessage } from './utils'; import { expect, test } from './utils/test'; test.use({ storageState: Users.admin.state }); test.describe.serial('Threads', () => { let poHomeChannel: HomeChannel; let targetChannel: string; + let targetChannelId: string; test.beforeAll(async ({ api }) => { - targetChannel = await createTargetChannel(api); + const { channel } = await createTargetChannelAndReturnFullRoom(api); + targetChannel = channel.name!; + targetChannelId = channel._id; }); test.beforeEach(async ({ page }) => { poHomeChannel = new HomeChannel(page); @@ -16,10 +19,11 @@ test.describe.serial('Threads', () => { await poHomeChannel.navbar.openChat(targetChannel); }); - test.afterAll(async ({ api }) => deleteChannel(api, targetChannel)); + test.afterAll(async ({ api }) => deleteRoom(api, targetChannelId)); - test('expect no unread banner when replying to a thread in a fresh channel', async ({ page }) => { - await poHomeChannel.content.sendMessage('parent for unread-banner test'); + test('expect no unread banner when replying to a thread in a fresh channel', async ({ api, page }) => { + await sendMessage(api, targetChannelId, 'parent for unread-banner test'); + await expect(poHomeChannel.content.lastUserMessage).toContainText('parent for unread-banner test'); await poHomeChannel.content.openReplyInThread(); await poHomeChannel.content.sendMessageInThread('first thread reply'); @@ -27,8 +31,9 @@ test.describe.serial('Threads', () => { await expect(page.getByTitle('Mark as read')).not.toBeVisible(); }); - test('expect thread message preview if alsoSendToChannel checkbox is checked', async ({ page }) => { - await poHomeChannel.content.sendMessage('this is a message for reply'); + test('expect thread message preview if alsoSendToChannel checkbox is checked', async ({ api, page }) => { + await sendMessage(api, targetChannelId, 'this is a message for reply'); + await expect(poHomeChannel.content.lastUserMessage).toContainText('this is a message for reply'); await poHomeChannel.content.openReplyInThread(); await expect(page).toHaveURL(/.*thread/); @@ -90,11 +95,11 @@ test.describe.serial('Threads', () => { }); test.describe('thread message actions', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ api, page }) => { poHomeChannel = new HomeChannel(page); + await sendMessage(api, targetChannelId, 'this is a message for reply'); await page.goto('/home'); await poHomeChannel.navbar.openChat(targetChannel); - await poHomeChannel.content.sendMessage('this is a message for reply'); await poHomeChannel.content.openReplyInThread(); }); From 7dff373c4f791c538cd4c637ccaa8fa05461a38a Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 23 Apr 2026 15:42:23 -0300 Subject: [PATCH 05/14] fix(e2e): wire asUser against absolute URL and add joinChannelAsUser Fixes surfaced while running the Phase 2 batch against a live server. - utils/messages.ts: Playwright's APIRequestContext resolves a path with a leading slash against the origin, not the baseURL. Calling userContext.post('/chat.sendMessage') was hitting /chat.sendMessage (404) instead of /api/v1/chat.sendMessage. Drop the baseURL and use the BASE_API_URL-prefixed absolute URL, matching the old sendMessageFromUser call. - utils/rooms.ts: add joinChannelAsUser(roomId, user) which performs /channels.join as the given user. Unlike inviteUsersToRoom (admin adds someone, emits "added" system message), this produces the single "joined" system message that UI-driven joins produce. - messaging.spec.ts: after navbar.openChat, click the composer to move keyboard focus there so the Navigation/Edition keyboard shortcuts have the right starting point the UI sendMessage used to provide. Seed user1's membership via joinChannelAsUser so the Navigation test's "first system message" assertion still resolves to exactly one system message. --- apps/meteor/tests/e2e/messaging.spec.ts | 8 +++++++- apps/meteor/tests/e2e/utils/messages.ts | 3 +-- apps/meteor/tests/e2e/utils/rooms.ts | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/meteor/tests/e2e/messaging.spec.ts b/apps/meteor/tests/e2e/messaging.spec.ts index 4438292e3ccd0..4acbc8d36d736 100644 --- a/apps/meteor/tests/e2e/messaging.spec.ts +++ b/apps/meteor/tests/e2e/messaging.spec.ts @@ -4,7 +4,7 @@ import type { Page } from '@playwright/test'; import { createAuxContext } from './fixtures/createAuxContext'; import { Users } from './fixtures/userStates'; import { HomeChannel } from './page-objects'; -import { createTargetChannelAndReturnFullRoom, deleteRoom, sendMessage } from './utils'; +import { createTargetChannelAndReturnFullRoom, deleteRoom, joinChannelAsUser, sendMessage } from './utils'; import { expect, test } from './utils/test'; test.use({ storageState: Users.user1.state }); @@ -19,6 +19,10 @@ test.describe('Messaging', () => { targetChannel = channel.name!; targetChannelId = channel._id; + // Match the original suite, which joined through the UI before sending — + // one "user joined" system message is a precondition for the first test. + await joinChannelAsUser(targetChannelId, Users.user1); + await sendMessage(api, targetChannelId, 'msg1', { asUser: Users.user1 }); await sendMessage(api, targetChannelId, 'msg2', { asUser: Users.user1 }); }); @@ -35,6 +39,7 @@ test.describe('Messaging', () => { test.describe.serial('Navigation', () => { test('should navigate on messages using keyboard', async ({ page }) => { await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.composer.inputMessage.click(); await test.step('move focus to the second message', async () => { await page.keyboard.press('Shift+Tab'); @@ -165,6 +170,7 @@ test.describe('Messaging', () => { test.describe.serial('Message edition', () => { test('should edit messages', async ({ page }) => { await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.composer.inputMessage.click(); await test.step('focus on the second message', async () => { await page.keyboard.press('ArrowUp'); diff --git a/apps/meteor/tests/e2e/utils/messages.ts b/apps/meteor/tests/e2e/utils/messages.ts index 14511d3d395a3..a44593dd2cfcb 100644 --- a/apps/meteor/tests/e2e/utils/messages.ts +++ b/apps/meteor/tests/e2e/utils/messages.ts @@ -21,14 +21,13 @@ export async function sendMessage(api: BaseTest['api'], roomId: string, msg: str if (options?.asUser) { const userContext = await baseRequest.newContext({ - baseURL: BASE_API_URL, extraHTTPHeaders: { 'X-Auth-Token': options.asUser.data.loginToken, 'X-User-Id': options.asUser.data._id, }, }); try { - const response = await userContext.post('/chat.sendMessage', { data: payload }); + const response = await userContext.post(`${BASE_API_URL}/chat.sendMessage`, { data: payload }); const data: { success?: boolean; message?: { _id: string } } = await response.json(); if (!data.success || !data.message?._id) { diff --git a/apps/meteor/tests/e2e/utils/rooms.ts b/apps/meteor/tests/e2e/utils/rooms.ts index 968a71dba508a..845ad7a129e92 100644 --- a/apps/meteor/tests/e2e/utils/rooms.ts +++ b/apps/meteor/tests/e2e/utils/rooms.ts @@ -1,6 +1,9 @@ +import { request as baseRequest } from '@playwright/test'; import type { IRoom, IUser } from '@rocket.chat/core-typings'; import type { BaseTest } from './test'; +import { BASE_API_URL } from '../config/constants'; +import type { IUserState } from '../fixtures/userStates'; export async function deleteChannel(api: BaseTest['api'], roomName: string): Promise { await api.post('/channels.delete', { roomName }); @@ -39,3 +42,20 @@ export async function inviteUsersToRoom(api: BaseTest['api'], roomId: string, us export async function setRoomTopic(api: BaseTest['api'], roomId: string, topic: string): Promise { await api.post('/rooms.saveRoomSettings', { rid: roomId, roomTopic: topic }); } + +// Joins a public channel as a specific user. Unlike inviteUsersToRoom (which is +// the admin adding someone), this emits the "user joined" system message that +// UI-driven joins produce — required when a spec asserts on system messages. +export async function joinChannelAsUser(roomId: string, asUser: IUserState): Promise { + const ctx = await baseRequest.newContext({ + extraHTTPHeaders: { + 'X-Auth-Token': asUser.data.loginToken, + 'X-User-Id': asUser.data._id, + }, + }); + try { + await ctx.post(`${BASE_API_URL}/channels.join`, { data: { roomId } }); + } finally { + await ctx.dispose(); + } +} From 531f9e11ea71a6a670d9f8cfba1a3b7f56ad8de2 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 23 Apr 2026 18:34:44 -0300 Subject: [PATCH 06/14] test(e2e): apply shared browser context to account-security MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2, pattern 2. account-security is a 6-test serial suite that all hit /account/security under the admin user — the precondition for a shared browser context is met. Moves context/page creation to beforeAll, keeps the per-test page.goto + the Accounts_Password_Policy toggle, tears the page down in afterAll. Verified against a live server: 6 passed (14.6s). account-profile.spec.ts attempted the same migration but Personal Access Tokens failed in a way (page.waitForResponse → "Target page has been closed") I could not reproduce without a running server; leaving the file at HEAD until it can be debugged live. --- .../meteor/tests/e2e/account-security.spec.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/meteor/tests/e2e/account-security.spec.ts b/apps/meteor/tests/e2e/account-security.spec.ts index f922cf8cb9935..550928701071d 100644 --- a/apps/meteor/tests/e2e/account-security.spec.ts +++ b/apps/meteor/tests/e2e/account-security.spec.ts @@ -1,4 +1,5 @@ import { faker } from '@faker-js/faker'; +import type { BrowserContext, Page } from 'playwright-core'; import { ADMIN_CREDENTIALS } from './config/constants'; import { Users } from './fixtures/userStates'; @@ -6,8 +7,6 @@ import { AccountSecurity } from './page-objects'; import { setSettingValueById, updateOwnUserPassword } from './utils'; import { test, expect } from './utils/test'; -test.use({ storageState: Users.admin.state }); - const RANDOM_PASSWORD = faker.helpers .shuffle([ faker.string.alpha({ casing: 'upper' }), @@ -20,22 +19,31 @@ const RANDOM_PASSWORD = faker.helpers test.describe.serial('account-security', () => { let poAccountSecurity: AccountSecurity; + let page: Page; + let context: BrowserContext; - test.beforeEach(async ({ page, api }) => { + test.beforeAll(async ({ browser }) => { + context = await browser.newContext({ storageState: Users.admin.state }); + page = await context.newPage(); poAccountSecurity = new AccountSecurity(page); + }); + + test.beforeEach(async ({ api }) => { await page.goto('/account/security'); await page.waitForSelector('#main-content'); await setSettingValueById(api, 'Accounts_Password_Policy_Enabled', false); }); - test.afterAll(async ({ api }) => - Promise.all([ + test.afterAll(async ({ api }) => { + await Promise.all([ setSettingValueById(api, 'Accounts_AllowPasswordChange', true), setSettingValueById(api, 'Accounts_TwoFactorAuthentication_Enabled', true), setSettingValueById(api, 'E2E_Enable', false), setSettingValueById(api, 'Accounts_Password_Policy_Enabled', true), - ]), - ); + ]); + await page.close(); + await context.close(); + }); test('should disable and enable email 2FA', async () => { await poAccountSecurity.security2FASection.click(); @@ -70,7 +78,7 @@ test.describe.serial('account-security', () => { ]); }); - test('security tab is invisible when password change, 2FA and E2E are disabled', async ({ page }) => { + test('security tab is invisible when password change, 2FA and E2E are disabled', async () => { const securityTab = poAccountSecurity.sidebar.linkSecurity; await expect(securityTab).not.toBeVisible(); const mainContent = page.locator('#main-content').getByText('You are not authorized to view this page.').first(); From b56ff210e2d6d63824a9db9c454eb7eed8e36a3b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 23 Apr 2026 18:44:08 -0300 Subject: [PATCH 07/14] chore(e2e): add phase 3 guardrails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the non-lint guardrails from the rollout plan; the optional ESLint rule is still open. - .github/PULL_REQUEST_TEMPLATE.md: point E2E reviewers at the "Anti-patterns to flag in review" section of the e2e README and the performance-migration plan. - apps/meteor/playwright.config.ts: enable Playwright's JSON reporter (tests/e2e/.playwright/results.json). The file rides along in the existing `playwright-test-trace-*` artifact uploads, so no workflow change is needed to expose it. - apps/meteor/tests/e2e/scripts/e2e-timing-report.mts: turns a results.json into a markdown table of specs whose p50 exceeds the 3s/test target (`ci_median_ms` baseline from the plan). Default threshold is configurable via CLI arg. - .github/workflows/e2e-timing-guardrail.yml: Mondays 09:00 UTC + manual dispatch; downloads the latest successful ci.yml artifact from develop and posts the report to the run summary, so recurring offenders surface without digging through Playwright HTML. - docs/proposals/e2e-performance-migration.md: Phase 3 section now reflects what landed and what is still open. - docs/proposals/e2e-migration-triage.md: regenerated after the Phase 2 batch — down from 9 to 5 files with UI setup hits. --- .github/PULL_REQUEST_TEMPLATE.md | 8 ++ .github/workflows/e2e-timing-guardrail.yml | 89 +++++++++++++++ apps/meteor/playwright.config.ts | 4 + .../tests/e2e/scripts/e2e-timing-report.mts | 106 ++++++++++++++++++ docs/proposals/e2e-migration-triage.md | 10 +- docs/proposals/e2e-performance-migration.md | 6 +- 6 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/e2e-timing-guardrail.yml create mode 100644 apps/meteor/tests/e2e/scripts/e2e-timing-report.mts diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 0b2af2ef78816..6169f942ae893 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -37,3 +37,11 @@ ## Further comments + + + diff --git a/.github/workflows/e2e-timing-guardrail.yml b/.github/workflows/e2e-timing-guardrail.yml new file mode 100644 index 0000000000000..ec0c27a59fd37 --- /dev/null +++ b/.github/workflows/e2e-timing-guardrail.yml @@ -0,0 +1,89 @@ +name: E2E Timing Guardrail + +on: + schedule: + # Mondays 09:00 UTC — the week's signal, before most merges land + - cron: '0 9 * * 1' + workflow_dispatch: + inputs: + threshold_ms: + description: 'p50 threshold per test, in ms' + required: false + default: '3000' + +permissions: + contents: read + actions: read + +jobs: + report: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + with: + ref: develop + + - uses: actions/setup-node@v5 + with: + node-version: 22 + + - name: Download the latest successful e2e trace artifact from develop + id: dl + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + run_id=$(gh run list \ + --branch develop \ + --workflow ci.yml \ + --status success \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId') + if [ -z "${run_id:-}" ]; then + echo "No successful ci.yml run on develop — skipping" >&2 + echo "has_artifact=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + mkdir -p artifacts + # Artifact name from ci-test-e2e.yml: playwright-test-trace--- + if ! gh run download "$run_id" \ + --pattern 'playwright-test-trace-*' \ + --dir artifacts; then + echo "No playwright-test-trace-* artifact in run $run_id — skipping" >&2 + echo "has_artifact=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "has_artifact=true" >> "$GITHUB_OUTPUT" + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + + - name: Generate timing report + if: steps.dl.outputs.has_artifact == 'true' + env: + THRESHOLD: ${{ inputs.threshold_ms || '3000' }} + RUN_ID: ${{ steps.dl.outputs.run_id }} + run: | + set -euo pipefail + # Each shard produces one results.json — concatenate their markdown + # outputs (threshold-filter runs per shard, dedup is cheap post-hoc). + { + echo "# E2E timing guardrail" + echo "" + echo "Source: [ci.yml run $RUN_ID](https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID) on \`develop\`." + echo "" + } >> "$GITHUB_STEP_SUMMARY" + found=0 + for json in artifacts/*/results.json; do + [ -f "$json" ] || continue + found=$((found + 1)) + shard=$(basename "$(dirname "$json")") + { + echo "## \`$shard\`" + echo "" + node --experimental-strip-types apps/meteor/tests/e2e/scripts/e2e-timing-report.mts "$json" "$THRESHOLD" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + done + if [ "$found" -eq 0 ]; then + echo "No results.json found in downloaded artifacts — the workflow may predate JSON-reporter rollout." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/apps/meteor/playwright.config.ts b/apps/meteor/playwright.config.ts index 1b27badd2e2d3..a1f3de42bb4c3 100644 --- a/apps/meteor/playwright.config.ts +++ b/apps/meteor/playwright.config.ts @@ -22,6 +22,10 @@ export default { outputDir: 'tests/e2e/.playwright', reporter: [ ['list'], + // JSON reporter — consumed by the weekly timing guardrail workflow + // (scripts/e2e-timing-report.mts) and by anyone wanting to analyse + // per-test durations locally. Small file, safe to always emit. + ['json', { outputFile: 'tests/e2e/.playwright/results.json' }], process.env.REPORTER_ROCKETCHAT_REPORT === 'true' && [ './reporters/rocketchat.ts', { diff --git a/apps/meteor/tests/e2e/scripts/e2e-timing-report.mts b/apps/meteor/tests/e2e/scripts/e2e-timing-report.mts new file mode 100644 index 0000000000000..03ba6b55aa932 --- /dev/null +++ b/apps/meteor/tests/e2e/scripts/e2e-timing-report.mts @@ -0,0 +1,106 @@ +/* + * Phase 3 timing guardrail: summarise per-spec p50 durations from a + * Playwright JSON report and flag files whose median test time exceeds + * the project's p50 < 3s/test target. + * + * Run with: + * node --experimental-strip-types apps/meteor/tests/e2e/scripts/e2e-timing-report.mts [threshold_ms] + * + * Produces a markdown summary on stdout (suitable for $GITHUB_STEP_SUMMARY). + * Default threshold is 3000ms per the plan. + */ + +import { readFileSync } from 'node:fs'; + +type PlaywrightTest = { + title: string; + results: { duration: number; status: string }[]; +}; + +type PlaywrightSpec = { + file: string; + tests: PlaywrightTest[]; + suites?: PlaywrightSuite[]; +}; + +type PlaywrightSuite = { + file?: string; + specs?: PlaywrightSpec[]; + suites?: PlaywrightSuite[]; +}; + +type PlaywrightReport = { + config: unknown; + suites: PlaywrightSuite[]; +}; + +function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2); +} + +function* walkSpecs(suites: PlaywrightSuite[] | undefined): Generator { + if (!suites) return; + for (const s of suites) { + if (s.specs) { + for (const spec of s.specs) { + yield { ...spec, file: spec.file || s.file || '' }; + } + } + yield* walkSpecs(s.suites); + } +} + +function main(): void { + const [reportPath, thresholdArg] = process.argv.slice(2); + if (!reportPath) { + console.error('Usage: e2e-timing-report.mts [threshold_ms]'); + process.exit(2); + } + + const threshold = thresholdArg ? Number(thresholdArg) : 3000; + const raw = readFileSync(reportPath, 'utf8'); + const report: PlaywrightReport = JSON.parse(raw); + + type Row = { file: string; tests: number; p50: number; total: number }; + const byFile = new Map(); + + for (const spec of walkSpecs(report.suites)) { + for (const t of spec.tests) { + // Use the last attempt's duration (retry-adjusted). Skip skipped/flaky statuses. + const last = t.results[t.results.length - 1]; + if (!last || last.status === 'skipped') continue; + const list = byFile.get(spec.file) ?? []; + list.push(last.duration); + byFile.set(spec.file, list); + } + } + + const rows: Row[] = Array.from(byFile.entries()) + .map(([file, durations]) => ({ + file, + tests: durations.length, + p50: median(durations), + total: durations.reduce((a, b) => a + b, 0), + })) + .filter((r) => r.p50 > threshold) + .sort((a, b) => b.p50 - a.p50); + + console.log(`# E2E p50 > ${threshold}ms\n`); + if (rows.length === 0) { + console.log(`All ${byFile.size} spec files are under the ${threshold}ms median target. 🎯\n`); + return; + } + console.log(`${rows.length} of ${byFile.size} spec files exceed the p50 < ${threshold}ms/test target.\n`); + console.log('| spec | tests | p50 (ms) | total (ms) |'); + console.log('| --- | --: | --: | --: |'); + for (const r of rows) { + console.log(`| \`${r.file}\` | ${r.tests} | ${r.p50} | ${r.total} |`); + } + console.log(''); + console.log('Recurring offenders are Phase 2 candidates for `docs/proposals/e2e-performance-migration.md`.'); +} + +main(); diff --git a/docs/proposals/e2e-migration-triage.md b/docs/proposals/e2e-migration-triage.md index d0c07c5e7c01b..595cb9fe7fe34 100644 --- a/docs/proposals/e2e-migration-triage.md +++ b/docs/proposals/e2e-migration-triage.md @@ -14,7 +14,7 @@ as a rough stand-in. - Candidates for Phase 2: 144 - Opt-out: 8 - Serial suites: 63 -- Specs with at least one UI setup hit: 9 +- Specs with at least one UI setup hit: 5 ## Phase 2 candidates @@ -22,12 +22,8 @@ Sorted by the stand-in priority until `ci_median_ms` is populated. | path | is_serial | ui_setup_hits | ci_median_ms | priority_score | | --- | :-: | --: | --: | --: | -| `report-message.spec.ts` | yes | 8 | — | — | -| `messaging.spec.ts` | yes | 2 | — | — | | `feature-preview.spec.ts` | yes | 1 | — | — | -| `image-gallery.spec.ts` | yes | 1 | — | — | | `quote-messages.spec.ts` | yes | 1 | — | — | -| `threads.spec.ts` | yes | 1 | — | — | | `account-profile.spec.ts` | yes | 0 | — | — | | `account-security.spec.ts` | yes | 0 | — | — | | `admin-room.spec.ts` | yes | 0 | — | — | @@ -43,6 +39,7 @@ Sorted by the stand-in priority until `ci_median_ms` is populated. | `files-management.spec.ts` | yes | 0 | — | — | | `global-search.spec.ts` | yes | 0 | — | — | | `homepage.spec.ts` | yes | 0 | — | — | +| `image-gallery.spec.ts` | yes | 0 | — | — | | `imports.spec.ts` | yes | 0 | — | — | | `jump-to-thread-message.spec.ts` | yes | 0 | — | — | | `mark-unread.spec.ts` | yes | 0 | — | — | @@ -50,6 +47,7 @@ Sorted by the stand-in priority until `ci_median_ms` is populated. | `message-composer.spec.ts` | yes | 0 | — | — | | `message-mentions.spec.ts` | yes | 0 | — | — | | `messaging-scroll-to-bottom.spec.ts` | yes | 0 | — | — | +| `messaging.spec.ts` | yes | 0 | — | — | | `notification-sounds.spec.ts` | yes | 0 | — | — | | `omnichannel/omnichannel-agents.spec.ts` | yes | 0 | — | — | | `omnichannel/omnichannel-appearance.spec.ts` | yes | 0 | — | — | @@ -71,6 +69,7 @@ Sorted by the stand-in priority until `ci_median_ms` is populated. | `presence.spec.ts` | yes | 0 | — | — | | `read-receipts-deactivated-users.spec.ts` | yes | 0 | — | — | | `read-receipts.spec.ts` | yes | 0 | — | — | +| `report-message.spec.ts` | yes | 0 | — | — | | `retention-policy.spec.ts` | yes | 0 | — | — | | `search-discussion.spec.ts` | yes | 0 | — | — | | `settings-assets.spec.ts` | yes | 0 | — | — | @@ -80,6 +79,7 @@ Sorted by the stand-in priority until `ci_median_ms` is populated. | `sidebar.spec.ts` | yes | 0 | — | — | | `system-messages.spec.ts` | yes | 0 | — | — | | `team-management.spec.ts` | yes | 0 | — | — | +| `threads.spec.ts` | yes | 0 | — | — | | `omnichannel/omnichannel-canned-responses-usage.spec.ts` | no | 1 | — | — | | `omnichannel/omnichannel-livechat-message-bubble-color.spec.ts` | no | 1 | — | — | | `quote-attachment.spec.ts` | no | 1 | — | — | diff --git a/docs/proposals/e2e-performance-migration.md b/docs/proposals/e2e-performance-migration.md index 87263cfc0bc21..1fb3e8b0b3a86 100644 --- a/docs/proposals/e2e-performance-migration.md +++ b/docs/proposals/e2e-performance-migration.md @@ -112,12 +112,12 @@ If any file in the batch regresses or stays flat, split that file out into its o Prevents regression after Phase 2 completes. Can land in parallel with Phase 2. -1. **Doc guardrail** — already in place via README "Anti-patterns to flag in review". Link to it from `.github/pull_request_template.md` under the E2E section. -2. **Lint guardrail (optional)** — a custom ESLint rule or a grep-based CI check that fails when a spec file: +1. **Doc guardrail** — in place. README "Anti-patterns to flag in review" exists and `.github/pull_request_template.md` points reviewers to it. +2. **Lint guardrail (optional)** — still open. A custom ESLint rule or a grep-based CI check that fails when a spec file: - Uses `poHomeChannel.content.sendMessage` inside `test.beforeEach` or `test.beforeAll`. - Declares `test.describe.serial` together with `beforeEach(async ({ page }) => { await page.goto(...) })`. Both are strong signals of missed Pattern 1 / Pattern 2 opportunities. -3. **Timing guardrail** — add a weekly GitHub Action (or extend an existing one) that parses the Playwright report from main and posts a list of spec files with p50 > 3s/test. Recurring offenders become Phase 2 candidates. +3. **Timing guardrail** — in place. `playwright.config.ts` now emits a JSON report; [`apps/meteor/tests/e2e/scripts/e2e-timing-report.mts`](../../apps/meteor/tests/e2e/scripts/e2e-timing-report.mts) turns it into a p50>3s/test table; [`.github/workflows/e2e-timing-guardrail.yml`](../../.github/workflows/e2e-timing-guardrail.yml) runs weekly (Mondays 09:00 UTC), pulls the latest successful `ci.yml` trace artifact from `develop` and posts the table as the run summary. ## Picking up the work From 2f25ba6e5cce3a25ec902e247b18ad98bccb4a8c Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 23 Apr 2026 23:22:15 -0300 Subject: [PATCH 08/14] test(e2e): apply shared browser context to four more specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 batch 3 — pattern 2 on 4 serial suites that met the shared-user precondition, validated against the live dev server. - account-profile.spec.ts — 8 of 9 tests (1 is test.skip by a pre-existing FIXME). The Personal Access Tokens test needs the 'create-personal-access-tokens' permission on the 'user' role; in CI this is seeded, locally it may not be. - admin-room.spec.ts — 6 tests, admin, beforeEach goto('/admin/rooms'). - emojis.spec.ts — 4 tests, admin, beforeEach goto('/home'). - file-upload.spec.ts — 14 tests across three nested describes. The video-message test now grants camera/microphone on the shared context (not the default test fixture) so the Video message button actually enables. --- apps/meteor/tests/e2e/account-profile.spec.ts | 40 +++++++++++++------ apps/meteor/tests/e2e/admin-room.spec.ts | 30 +++++++++----- apps/meteor/tests/e2e/emojis.spec.ts | 32 ++++++++++----- apps/meteor/tests/e2e/file-upload.spec.ts | 23 +++++++---- 4 files changed, 85 insertions(+), 40 deletions(-) diff --git a/apps/meteor/tests/e2e/account-profile.spec.ts b/apps/meteor/tests/e2e/account-profile.spec.ts index 7ee63abe1cdd2..771906949cf47 100644 --- a/apps/meteor/tests/e2e/account-profile.spec.ts +++ b/apps/meteor/tests/e2e/account-profile.spec.ts @@ -1,4 +1,6 @@ +import AxeBuilder from '@axe-core/playwright'; import { faker } from '@faker-js/faker'; +import type { BrowserContext, Page } from 'playwright-core'; import { Users } from './fixtures/userStates'; import { HomeChannel, AccountProfile } from './page-objects'; @@ -9,17 +11,31 @@ test.use({ storageState: Users.user3.state }); test.describe.serial('settings-account-profile', () => { let poHomeChannel: HomeChannel; let poAccountProfile: AccountProfile; + let page: Page; + let context: BrowserContext; const token = faker.string.alpha(10); - - test.beforeEach(async ({ page }) => { + const axe = () => + new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .include('body') + .disableRules(['aria-hidden-focus', 'nested-interactive']); + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext({ storageState: Users.user3.state }); + page = await context.newPage(); poHomeChannel = new HomeChannel(page); poAccountProfile = new AccountProfile(page); }); + test.afterAll(async () => { + await page.close(); + await context.close(); + }); + // FIXME: solve test intermitencies test.describe('Profile', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async () => { await page.goto('/account/profile'); }); @@ -74,10 +90,8 @@ test.describe.serial('settings-account-profile', () => { }); }); - test('Personal Access Tokens', async ({ page }) => { - const response = page.waitForResponse('**/api/v1/users.getPersonalAccessTokens'); - await page.goto('/account/tokens'); - await response; + test('Personal Access Tokens', async () => { + await Promise.all([page.waitForResponse('**/api/v1/users.getPersonalAccessTokens'), page.goto('/account/tokens')]); await test.step('should show empty personal access tokens table', async () => { await expect(poAccountProfile.tokensTableEmpty).toBeVisible(); @@ -117,28 +131,28 @@ test.describe.serial('settings-account-profile', () => { }); test.describe('Omnichannel', () => { - test('should not have any accessibility violations', async ({ page, makeAxeBuilder }) => { + test('should not have any accessibility violations', async () => { await page.goto('/account/omnichannel'); - const results = await makeAxeBuilder().analyze(); + const results = await axe().analyze(); expect(results.violations).toEqual([]); }); }); test.describe('Feature Preview', () => { - test('should not have any accessibility violations', async ({ page, makeAxeBuilder }) => { + test('should not have any accessibility violations', async () => { await page.goto('/account/feature-preview'); - const results = await makeAxeBuilder().analyze(); + const results = await axe().analyze(); expect(results.violations).toEqual([]); }); }); test.describe('Accessibility & Appearance', () => { - test('should not have any accessibility violations', async ({ page, makeAxeBuilder }) => { + test('should not have any accessibility violations', async () => { await page.goto('/account/accessibility-and-appearance'); - const results = await makeAxeBuilder().analyze(); + const results = await axe().analyze(); expect(results.violations).toEqual([]); }); }); diff --git a/apps/meteor/tests/e2e/admin-room.spec.ts b/apps/meteor/tests/e2e/admin-room.spec.ts index 73306ba016017..5f0b417e45485 100644 --- a/apps/meteor/tests/e2e/admin-room.spec.ts +++ b/apps/meteor/tests/e2e/admin-room.spec.ts @@ -1,4 +1,5 @@ import { faker } from '@faker-js/faker'; +import type { BrowserContext, Page } from 'playwright-core'; import { Users } from './fixtures/userStates'; import { AdminInfo, AdminRooms, AdminSectionsHref } from './page-objects'; @@ -12,28 +13,37 @@ test.describe.serial('admin-rooms', () => { let privateRoom: string; let adminRooms: AdminRooms; let adminInfo: AdminInfo; + let page: Page; + let context: BrowserContext; - test.beforeEach(async ({ page }) => { + test.beforeAll(async ({ browser, api }) => { + [channel, privateRoom] = await Promise.all([createTargetChannel(api), createTargetPrivateChannel(api)]); + context = await browser.newContext({ storageState: Users.admin.state }); + page = await context.newPage(); adminRooms = new AdminRooms(page); - await page.goto('/admin/rooms'); }); - test.beforeAll(async ({ api }) => { - [channel, privateRoom] = await Promise.all([createTargetChannel(api), createTargetPrivateChannel(api)]); + test.afterAll(async () => { + await page.close(); + await context.close(); + }); + + test.beforeEach(async () => { + await page.goto('/admin/rooms'); }); - test('should display the Rooms Table', async ({ page }) => { + test('should display the Rooms Table', async () => { await expect(page.getByRole('main').getByRole('heading', { level: 1, name: 'Rooms', exact: true })).toBeVisible(); await expect(page.getByRole('main').getByRole('table')).toBeVisible(); }); - test('should filter room by name', async ({ page }) => { + test('should filter room by name', async () => { await adminRooms.inputSearchRooms.fill(channel); await expect(page.locator(`[qa-room-name="${channel}"]`)).toBeVisible(); }); - test('should filter rooms by type', async ({ page }) => { + test('should filter rooms by type', async () => { const dropdown = await adminRooms.dropdownFilterRoomType(); await dropdown.click(); @@ -48,7 +58,7 @@ test.describe.serial('admin-rooms', () => { await expect(page.locator('text=Private Channel').first()).toBeVisible(); }); - test('should filter rooms by type and name', async ({ page }) => { + test('should filter rooms by type and name', async () => { await adminRooms.inputSearchRooms.fill(privateRoom); const dropdown = await adminRooms.dropdownFilterRoomType(); @@ -59,7 +69,7 @@ test.describe.serial('admin-rooms', () => { await expect(page.locator(`[qa-room-name="${privateRoom}"]`)).toBeVisible(); }); - test('should be empty in case of the search does not find any room', async ({ page }) => { + test('should be empty in case of the search does not find any room', async () => { const nonExistingChannel = faker.string.alpha(10); await adminRooms.inputSearchRooms.fill(nonExistingChannel); @@ -72,7 +82,7 @@ test.describe.serial('admin-rooms', () => { await expect(page.locator('text=No results found')).toBeVisible(); }); - test('should filter rooms by type and name and clean the filter after changing section', async ({ page }) => { + test('should filter rooms by type and name and clean the filter after changing section', async () => { adminInfo = new AdminInfo(page); await adminRooms.inputSearchRooms.fill(privateRoom); diff --git a/apps/meteor/tests/e2e/emojis.spec.ts b/apps/meteor/tests/e2e/emojis.spec.ts index 6f0c99284c525..ceb9897cdbdc2 100644 --- a/apps/meteor/tests/e2e/emojis.spec.ts +++ b/apps/meteor/tests/e2e/emojis.spec.ts @@ -1,6 +1,8 @@ +import type { BrowserContext, Page } from 'playwright-core'; + import { Users } from './fixtures/userStates'; import { HomeChannel, AdminEmoji } from './page-objects'; -import { createTargetChannel } from './utils'; +import { createTargetChannelAndReturnFullRoom, deleteRoom } from './utils'; import { test, expect } from './utils/test'; test.use({ storageState: Users.admin.state }); @@ -9,18 +11,30 @@ test.describe.serial('emoji', () => { let poHomeChannel: HomeChannel; let poAdminEmoji: AdminEmoji; let targetChannel: string; - - test.beforeAll(async ({ api }) => { - targetChannel = await createTargetChannel(api); + let targetChannelId: string; + let page: Page; + let context: BrowserContext; + + test.beforeAll(async ({ api, browser }) => { + const { channel } = await createTargetChannelAndReturnFullRoom(api); + targetChannel = channel.name!; + targetChannelId = channel._id; + context = await browser.newContext({ storageState: Users.admin.state }); + page = await context.newPage(); + poHomeChannel = new HomeChannel(page); }); - test.beforeEach(async ({ page }) => { - poHomeChannel = new HomeChannel(page); + test.afterAll(async ({ api }) => { + await deleteRoom(api, targetChannelId); + await page.close(); + await context.close(); + }); + test.beforeEach(async () => { await page.goto('/home'); }); - test('should display emoji picker properly', async ({ page }) => { + test('should display emoji picker properly', async () => { await poHomeChannel.navbar.openChat(targetChannel); await poHomeChannel.composer.btnEmoji.click(); @@ -45,7 +59,7 @@ test.describe.serial('emoji', () => { }); }); - test('expect send emoji via text', async ({ page }) => { + test('expect send emoji via text', async () => { await poHomeChannel.navbar.openChat(targetChannel); await poHomeChannel.content.sendMessage(':innocent:'); await page.keyboard.press('Enter'); @@ -60,7 +74,7 @@ test.describe.serial('emoji', () => { await expect(poHomeChannel.content.lastUserMessage).toContainText('® © ™ # *'); }); - test('should add a custom emoji, send it, rename it, and check render', async ({ page }) => { + test('should add a custom emoji, send it, rename it, and check render', async () => { const emojiName = 'customemoji'; const newEmojiName = 'renamedemoji'; const emojiUrl = './tests/e2e/fixtures/files/test-image.jpeg'; diff --git a/apps/meteor/tests/e2e/file-upload.spec.ts b/apps/meteor/tests/e2e/file-upload.spec.ts index 7c3792fd5c972..c409b4dfa2487 100644 --- a/apps/meteor/tests/e2e/file-upload.spec.ts +++ b/apps/meteor/tests/e2e/file-upload.spec.ts @@ -1,3 +1,5 @@ +import type { BrowserContext, Page } from 'playwright-core'; + import { Users } from './fixtures/userStates'; import { HomeChannel } from './page-objects'; import { FileUploadWarningModal } from './page-objects/fragments/modals'; @@ -15,15 +17,18 @@ const TEST_EMPTY_FILE = 'empty_file.txt'; test.describe.serial('file-upload', () => { let poHomeChannel: HomeChannel; let targetChannel: string; + let page: Page; + let context: BrowserContext; - test.beforeAll(async ({ api }) => { + test.beforeAll(async ({ api, browser }) => { await setSettingValueById(api, 'FileUpload_MediaTypeBlackList', 'image/svg+xml'); targetChannel = await createTargetChannel(api, { members: ['user1'] }); - }); - - test.beforeEach(async ({ page }) => { + context = await browser.newContext({ storageState: Users.user1.state }); + page = await context.newPage(); poHomeChannel = new HomeChannel(page); + }); + test.beforeEach(async () => { await page.goto('/home'); await poHomeChannel.navbar.openChat(targetChannel); }); @@ -31,6 +36,8 @@ test.describe.serial('file-upload', () => { test.afterAll(async ({ api }) => { await setSettingValueById(api, 'FileUpload_MediaTypeBlackList', 'image/svg+xml'); expect((await api.post('/channels.delete', { roomName: targetChannel })).status()).toBe(200); + await page.close(); + await context.close(); }); test('should cancel uploaded file attached to message composer', async () => { @@ -86,7 +93,7 @@ test.describe.serial('file-upload', () => { await expect(poHomeChannel.content.getLastMessageByFileName(TEST_FILE_LST)).toBeVisible(); }); - test('should send drawio (unknown media type) file successfully', async ({ page }) => { + test('should send drawio (unknown media type) file successfully', async () => { await page.reload(); await poHomeChannel.content.sendFileMessage(TEST_FILE_DRAWIO); await poHomeChannel.composer.inputMessage.fill('drawio_description'); @@ -130,7 +137,7 @@ test.describe.serial('file-upload', () => { await expect(poHomeChannel.composer.getFileByName('any_file.txt')).not.toBeVisible(); }); - test('should upload file in composer after recording video message', async ({ context }) => { + test('should upload file in composer after recording video message', async () => { await context.grantPermissions(['camera', 'microphone']); await poHomeChannel.navbar.openChat(targetChannel); @@ -165,7 +172,7 @@ test.describe.serial('file-upload', () => { await setSettingValueById(api, 'FileUpload_MediaTypeBlackList', 'image/svg+xml'); }); - test('should open warning modal when all file uploads fail', async ({ page }) => { + test('should open warning modal when all file uploads fail', async () => { fileUploadWarningModal = new FileUploadWarningModal(page.getByRole('dialog', { name: 'Warning' })); await poHomeChannel.content.sendFileMessage(TEST_EMPTY_FILE, { waitForResponse: false }); @@ -182,7 +189,7 @@ test.describe.serial('file-upload', () => { await expect(fileUploadWarningModal.btnSendAnyway).not.toBeVisible(); }); - test('should handle multiple files with one failing upload', async ({ page }) => { + test('should handle multiple files with one failing upload', async () => { fileUploadWarningModal = new FileUploadWarningModal(page.getByRole('dialog', { name: 'Are you sure' })); await test.step('should only mark as "Upload failed" the specific file that failed to upload', async () => { From 3deacb473f880584b851243b3ac67822051c2289 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 23 Apr 2026 23:27:23 -0300 Subject: [PATCH 09/14] test(e2e): pattern 2 on jump-to-thread, email-inboxes, scroll-to-bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more serial, single-user suites that meet the shared-context precondition. - jump-to-thread-message.spec.ts — 3 tests, admin, each test goto to a different message link. - email-inboxes.spec.ts — 2 tests, admin, beforeEach goto /admin/email-inboxes. (The delete test assumes an empty inboxes table — leftover inboxes from older runs break it; not a migration regression.) - messaging-scroll-to-bottom.spec.ts — 4 tests, admin. Moves the shared 1023x700 viewport into browser.newContext so each test doesn't have to reset it. --- apps/meteor/tests/e2e/email-inboxes.spec.ts | 20 ++++++++---- .../tests/e2e/jump-to-thread-message.spec.ts | 19 +++++++++--- .../e2e/messaging-scroll-to-bottom.spec.ts | 31 +++++++++---------- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/apps/meteor/tests/e2e/email-inboxes.spec.ts b/apps/meteor/tests/e2e/email-inboxes.spec.ts index 384532390e609..a57c368ee0f1c 100644 --- a/apps/meteor/tests/e2e/email-inboxes.spec.ts +++ b/apps/meteor/tests/e2e/email-inboxes.spec.ts @@ -1,4 +1,5 @@ import { faker } from '@faker-js/faker'; +import type { BrowserContext, Page } from 'playwright-core'; import { Users } from './fixtures/userStates'; import { AdminEmailInboxes } from './page-objects'; @@ -8,12 +9,23 @@ test.use({ storageState: Users.admin.state }); test.describe.serial('email-inboxes', () => { let poAdminEmailInboxes: AdminEmailInboxes; + let page: Page; + let context: BrowserContext; const email = faker.internet.email(); - test.beforeEach(async ({ page }) => { + test.beforeAll(async ({ browser }) => { + context = await browser.newContext({ storageState: Users.admin.state }); + page = await context.newPage(); poAdminEmailInboxes = new AdminEmailInboxes(page); + }); + + test.afterAll(async () => { + await page.close(); + await context.close(); + }); + test.beforeEach(async () => { await page.goto('/admin/email-inboxes'); }); @@ -40,12 +52,8 @@ test.describe.serial('email-inboxes', () => { await expect(poAdminEmailInboxes.itemRow(name)).toBeVisible(); }); - test('expect delete an email inbox', async ({ page }) => { + test('expect delete an email inbox', async () => { await poAdminEmailInboxes.deleteEmailInboxByName(email); - // await poAdminEmailInboxes.itemRow(email).click(); - // await poAdminEmailInboxes.btnDelete.click(); - // await poUtils.btnModalConfirmDelete.click(); - // await expect(poUtils.toastBarSuccess).toBeVisible(); await expect(page.locator('text=No results found')).toBeVisible(); }); diff --git a/apps/meteor/tests/e2e/jump-to-thread-message.spec.ts b/apps/meteor/tests/e2e/jump-to-thread-message.spec.ts index a55f55cbfbc23..0cc6151d4b37e 100644 --- a/apps/meteor/tests/e2e/jump-to-thread-message.spec.ts +++ b/apps/meteor/tests/e2e/jump-to-thread-message.spec.ts @@ -1,5 +1,6 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; +import type { BrowserContext, Page } from 'playwright-core'; import { Users } from './fixtures/userStates'; import type { BaseTest } from './utils/test'; @@ -10,6 +11,8 @@ test.describe.serial('Threads', () => { let targetChannel: { name: string; _id: string }; let threadMessage: IMessage; let mainMessage: IMessage; + let page: Page; + let context: BrowserContext; const fillMessages = async (api: BaseTest['api']) => { const { message: parentMessage } = await ( @@ -36,14 +39,20 @@ test.describe.serial('Threads', () => { ); }; - test.beforeAll(async ({ api }) => { + test.beforeAll(async ({ api, browser }) => { targetChannel = (await (await api.post('/channels.create', { name: Random.id() })).json()).channel; await fillMessages(api); + context = await browser.newContext({ storageState: Users.admin.state }); + page = await context.newPage(); }); - test.afterAll(({ api }) => api.post('/channels.delete', { roomId: targetChannel._id })); + test.afterAll(async ({ api }) => { + await api.post('/channels.delete', { roomId: targetChannel._id }); + await page.close(); + await context.close(); + }); - test('expect to jump scroll to a non-thread message on opening its message link', async ({ page }) => { + test('expect to jump scroll to a non-thread message on opening its message link', async () => { const messageLink = `/channel/${targetChannel.name}?msg=${mainMessage._id}`; await page.goto(messageLink); @@ -58,7 +67,7 @@ test.describe.serial('Threads', () => { await expect(message).toBeInViewport(); }); - test('expect to jump scroll to thread message on opening its message link', async ({ page }) => { + test('expect to jump scroll to thread message on opening its message link', async () => { const threadMessageLink = `/channel/${targetChannel.name}?msg=${threadMessage._id}`; await page.goto(threadMessageLink); @@ -73,7 +82,7 @@ test.describe.serial('Threads', () => { await expect(message).toBeInViewport(); }); - test('expect to jump scroll to thread message on opening its message link from a different channel', async ({ page }) => { + test('expect to jump scroll to thread message on opening its message link from a different channel', async () => { const threadMessageLink = `/channel/general?msg=${threadMessage._id}`; await page.goto(threadMessageLink); diff --git a/apps/meteor/tests/e2e/messaging-scroll-to-bottom.spec.ts b/apps/meteor/tests/e2e/messaging-scroll-to-bottom.spec.ts index 57b426ba200b0..08b82d0f2003e 100644 --- a/apps/meteor/tests/e2e/messaging-scroll-to-bottom.spec.ts +++ b/apps/meteor/tests/e2e/messaging-scroll-to-bottom.spec.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import type { Locator } from 'playwright-core'; +import type { BrowserContext, Locator, Page } from 'playwright-core'; import { Users } from './fixtures/userStates'; import { HomeChannel } from './page-objects'; @@ -12,6 +12,8 @@ test.describe.serial('Messaging scroll to bottom', () => { let targetChannel: { name: string; _id: string }; let poHomeChannel: HomeChannel; let mainMessage: { _id: string }; + let page: Page; + let context: BrowserContext; const fillMessages = async (api: BaseTest['api']) => { const firstResponse = await api.post('/chat.postMessage', { @@ -53,7 +55,7 @@ test.describe.serial('Messaging scroll to bottom', () => { await expect.poll(() => scroller.evaluate((el) => Math.abs(el.scrollTop - (el.scrollHeight - el.clientHeight)) < 2)).toBe(true); }; - test.beforeAll(async ({ api }) => { + test.beforeAll(async ({ api, browser }) => { await api .post('/channels.create', { name: faker.string.uuid() }) .then((res) => res.json()) @@ -62,10 +64,13 @@ test.describe.serial('Messaging scroll to bottom', () => { }); await fillMessages(api); - }); - test.beforeEach(async ({ page }) => { + context = await browser.newContext({ storageState: Users.admin.state, viewport: { width: 1023, height: 700 } }); + page = await context.newPage(); poHomeChannel = new HomeChannel(page); + }); + + test.beforeEach(async () => { await page.goto(`/channel/${targetChannel._id}/thread/${mainMessage._id}`); await poHomeChannel.content.waitForChannel(); await poHomeChannel.content.waitForThread(); @@ -73,11 +78,11 @@ test.describe.serial('Messaging scroll to bottom', () => { test.afterAll(async ({ api }) => { await deleteChannel(api, targetChannel.name); + await page.close(); + await context.close(); }); - test('should scroll the main message list to bottom when sending a message in the main channel', async ({ page }) => { - await page.setViewportSize({ width: 1023, height: 700 }); - + test('should scroll the main message list to bottom when sending a message in the main channel', async () => { await scrollToTop(poHomeChannel.content.mainMessageListScroller); await poHomeChannel.content.sendMessage('main channel message'); @@ -85,9 +90,7 @@ test.describe.serial('Messaging scroll to bottom', () => { await expectScrolledToBottom(poHomeChannel.content.mainMessageListScroller); }); - test('should scroll the thread message list to bottom when sending a message in the thread', async ({ page }) => { - await page.setViewportSize({ width: 1023, height: 700 }); - + test('should scroll the thread message list to bottom when sending a message in the thread', async () => { await scrollToTop(poHomeChannel.content.threadMessageListScroller); await poHomeChannel.content.sendMessageInThread('new thread reply'); @@ -95,9 +98,7 @@ test.describe.serial('Messaging scroll to bottom', () => { await expectScrolledToBottom(poHomeChannel.content.threadMessageListScroller); }); - test('should not scroll the main channel message list when sending a message in the thread', async ({ page }) => { - await page.setViewportSize({ width: 1023, height: 700 }); - + test('should not scroll the main channel message list when sending a message in the thread', async () => { await scrollToTop(poHomeChannel.content.mainMessageListScroller); await scrollToTop(poHomeChannel.content.threadMessageListScroller); @@ -107,9 +108,7 @@ test.describe.serial('Messaging scroll to bottom', () => { await expect(poHomeChannel.content.mainMessageListScroller).toHaveJSProperty('scrollTop', 0); }); - test('should not scroll the thread message list when sending a message in the main channel', async ({ page }) => { - await page.setViewportSize({ width: 1023, height: 700 }); - + test('should not scroll the thread message list when sending a message in the main channel', async () => { await scrollToTop(poHomeChannel.content.mainMessageListScroller); await scrollToTop(poHomeChannel.content.threadMessageListScroller); From 86fd24f8fc6c33ef0f1fa79242ea0f8e25f96e07 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 23 Apr 2026 23:31:38 -0300 Subject: [PATCH 10/14] test(e2e): share browser context across imports.spec.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 admin tests against /admin/import and /admin/rooms — each was re-creating the AdminImports / AdminRooms page-objects per test. Move context, page and page-objects into beforeAll so the suite pays the hydration cost once. --- apps/meteor/tests/e2e/imports.spec.ts | 49 ++++++++++++++++----------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/apps/meteor/tests/e2e/imports.spec.ts b/apps/meteor/tests/e2e/imports.spec.ts index 0c9a4e3dd63e6..a6fd60fce1ee6 100644 --- a/apps/meteor/tests/e2e/imports.spec.ts +++ b/apps/meteor/tests/e2e/imports.spec.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import * as path from 'path'; import { parse } from 'csv-parse'; +import type { BrowserContext, Page } from 'playwright-core'; import { Users } from './fixtures/userStates'; import { AdminImports, AdminRooms } from './page-objects'; @@ -80,14 +81,28 @@ const roomsCsvToJson = (): Promise => ); test.describe.serial('imports', () => { - test.beforeAll(async () => { + let page: Page; + let context: BrowserContext; + let poAdminImports: AdminImports; + let poAdminRooms: AdminRooms; + + test.beforeAll(async ({ browser }) => { await usersCsvsToJson(); await roomsCsvToJson(); await countDmMessages(); + + context = await browser.newContext({ storageState: Users.admin.state }); + page = await context.newPage(); + poAdminImports = new AdminImports(page); + poAdminRooms = new AdminRooms(page); + }); + + test.afterAll(async () => { + await page.close(); + await context.close(); }); - test('expect import users data from slack', async ({ page }) => { - const poAdminImports = new AdminImports(page); + test('expect import users data from slack', async () => { await page.goto('/admin/import'); await poAdminImports.btnImportNewFile.click(); @@ -104,8 +119,7 @@ test.describe.serial('imports', () => { }); }); - test('expect import users data from zipped CSV files', async ({ page }) => { - const poAdminImports = new AdminImports(page); + test('expect import users data from zipped CSV files', async () => { await page.goto('/admin/import'); await poAdminImports.btnImportNewFile.click(); @@ -124,7 +138,7 @@ test.describe.serial('imports', () => { }); }); - test('expect all imported users to be actually listed as users', async ({ page }) => { + test('expect all imported users to be actually listed as users', async () => { await page.goto('/admin/users'); for await (const user of rowUserName) { @@ -136,39 +150,36 @@ test.describe.serial('imports', () => { } }); - test('expect all imported rooms to be actually listed as rooms with correct members count', async ({ page }) => { - const poAdmin: AdminRooms = new AdminRooms(page); + test('expect all imported rooms to be actually listed as rooms with correct members count', async () => { await page.goto('/admin/rooms'); for await (const room of importedRooms) { - await poAdmin.inputSearchRooms.fill(room.name); + await poAdminRooms.inputSearchRooms.fill(room.name); const expectedMembersCount = room.members.split(';').filter((username) => username !== room.ownerUsername).length + 1; expect(page.locator(`tbody tr td:nth-child(2) >> text="${expectedMembersCount}"`)); } }); - test('expect all imported rooms to have correct room type and owner', async ({ page }) => { - const poAdmin = new AdminRooms(page); + test('expect all imported rooms to have correct room type and owner', async () => { await page.goto('/admin/rooms'); for await (const room of importedRooms) { - await poAdmin.inputSearchRooms.fill(room.name); - await poAdmin.getRoomRow(room.name).click(); + await poAdminRooms.inputSearchRooms.fill(room.name); + await poAdminRooms.getRoomRow(room.name).click(); room.visibility === 'private' - ? await expect(poAdmin.editRoom.privateInput).toBeChecked() - : await expect(poAdmin.editRoom.privateInput).not.toBeChecked(); - await expect(poAdmin.editRoom.roomOwnerInput).toHaveValue(room.ownerUsername); + ? await expect(poAdminRooms.editRoom.privateInput).toBeChecked() + : await expect(poAdminRooms.editRoom.privateInput).not.toBeChecked(); + await expect(poAdminRooms.editRoom.roomOwnerInput).toHaveValue(room.ownerUsername); } }); - test('expect imported DM to be actually listed as a room with correct members and messages count', async ({ page }) => { - const poAdmin = new AdminRooms(page); + test('expect imported DM to be actually listed as a room with correct members and messages count', async () => { await page.goto('/admin/rooms'); for await (const user of csvImportedUsernames) { - await poAdmin.inputSearchRooms.fill(user); + await poAdminRooms.inputSearchRooms.fill(user); expect(page.locator(`tbody tr td:first-child >> text="${user}"`)); const expectedMembersCount = 2; From d5af806d78b24be4f55018c6d7aa469cf421f151 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 23 Apr 2026 23:44:38 -0300 Subject: [PATCH 11/14] test(e2e): pattern 2 on system-messages, settings-assets, sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more serial admin suites share a browser context. - system-messages.spec.ts — 4 tests, page-object + page moved to beforeAll. Each test still does its own /home goto + openChat in beforeEach. - settings-assets.spec.ts — 4 tests, same shape. - sidebar.spec.ts — 14 tests, the trickier one. Tests shrink the viewport to 1023x767 / 767x510 for mobile/tablet checks; beforeEach now resets to 1280x720 so a previous test's mobile size doesn't leak in. --- apps/meteor/tests/e2e/settings-assets.spec.ts | 24 ++++++++-- apps/meteor/tests/e2e/sidebar.spec.ts | 47 +++++++++++-------- apps/meteor/tests/e2e/system-messages.spec.ts | 22 ++++++--- 3 files changed, 61 insertions(+), 32 deletions(-) diff --git a/apps/meteor/tests/e2e/settings-assets.spec.ts b/apps/meteor/tests/e2e/settings-assets.spec.ts index f2f83360567b8..69da9ed738c81 100644 --- a/apps/meteor/tests/e2e/settings-assets.spec.ts +++ b/apps/meteor/tests/e2e/settings-assets.spec.ts @@ -1,3 +1,5 @@ +import type { BrowserContext, Page } from 'playwright-core'; + import { Users } from './fixtures/userStates'; import { AdminSettings } from './page-objects'; import { test, expect } from './utils/test'; @@ -6,9 +8,21 @@ test.use({ storageState: Users.admin.state }); test.describe.serial('settings-assets', () => { let poAdminSettings: AdminSettings; + let page: Page; + let context: BrowserContext; - test.beforeEach(async ({ page }) => { + test.beforeAll(async ({ browser }) => { + context = await browser.newContext({ storageState: Users.admin.state }); + page = await context.newPage(); poAdminSettings = new AdminSettings(page); + }); + + test.afterAll(async () => { + await page.close(); + await context.close(); + }); + + test.beforeEach(async () => { await page.goto('/admin/settings'); await poAdminSettings.btnAssetsSettings.click(); @@ -16,7 +30,7 @@ test.describe.serial('settings-assets', () => { await expect(page.getByRole('main').getByRole('heading', { level: 1, name: 'Assets', exact: true })).toBeVisible(); }); - test('expect upload and delete logo asset and label should be visible', async ({ page }) => { + test('expect upload and delete logo asset and label should be visible', async () => { await expect(page.locator('[title="Assets_logo"]')).toHaveText('logo (svg, png, jpg)'); await poAdminSettings.inputAssetsLogo.setInputFiles('./tests/e2e/fixtures/files/test-image.jpeg'); @@ -28,7 +42,7 @@ test.describe.serial('settings-assets', () => { await expect(page.locator('role=img[name="Asset preview"]')).not.toBeVisible(); }); - test('expect upload and delete logo asset for dark theme and label should be visible', async ({ page }) => { + test('expect upload and delete logo asset for dark theme and label should be visible', async () => { await expect(page.locator('[title="Assets_logo_dark"]')).toHaveText('logo - dark theme (svg, png, jpg)'); await poAdminSettings.inputAssetsLogo.setInputFiles('./tests/e2e/fixtures/files/test-image.jpeg'); @@ -39,7 +53,7 @@ test.describe.serial('settings-assets', () => { await expect(page.locator('role=img[name="Asset preview"]')).not.toBeVisible(); }); - test('expect upload and delete background asset and label should be visible', async ({ page }) => { + test('expect upload and delete background asset and label should be visible', async () => { await expect(page.locator('[title="Assets_background"]')).toHaveText('login background (svg, png, jpg)'); await poAdminSettings.inputAssetsLogo.setInputFiles('./tests/e2e/fixtures/files/test-image.jpeg'); @@ -50,7 +64,7 @@ test.describe.serial('settings-assets', () => { await expect(page.locator('role=img[name="Asset preview"]')).not.toBeVisible(); }); - test('expect upload and delete background asset for dark theme and label should be visible', async ({ page }) => { + test('expect upload and delete background asset for dark theme and label should be visible', async () => { await expect(page.locator('[title="Assets_background_dark"]')).toHaveText('login background - dark theme (svg, png, jpg)'); await poAdminSettings.inputAssetsLogo.setInputFiles('./tests/e2e/fixtures/files/test-image.jpeg'); diff --git a/apps/meteor/tests/e2e/sidebar.spec.ts b/apps/meteor/tests/e2e/sidebar.spec.ts index 99d855eac81e2..1e9ffc9965233 100644 --- a/apps/meteor/tests/e2e/sidebar.spec.ts +++ b/apps/meteor/tests/e2e/sidebar.spec.ts @@ -1,25 +1,37 @@ +import type { BrowserContext, Page } from 'playwright-core'; + import { Users } from './fixtures/userStates'; import { HomeChannel } from './page-objects'; import { deleteChannel, createTargetChannel } from './utils'; import { test, expect } from './utils/test'; +const DEFAULT_VIEWPORT = { width: 1280, height: 720 }; + test.use({ storageState: Users.admin.state }); test.describe.serial('Sidebar', () => { let poHomeChannel: HomeChannel; let targetChannel: string; + let page: Page; + let context: BrowserContext; - test.beforeAll(async ({ api }) => { + test.beforeAll(async ({ api, browser }) => { targetChannel = await createTargetChannel(api, { members: ['user1'] }); + context = await browser.newContext({ storageState: Users.admin.state, viewport: DEFAULT_VIEWPORT }); + page = await context.newPage(); + poHomeChannel = new HomeChannel(page); }); test.afterAll(async ({ api }) => { await deleteChannel(api, targetChannel); + await page.close(); + await context.close(); }); - test.beforeEach(async ({ page }) => { - poHomeChannel = new HomeChannel(page); - + test.beforeEach(async () => { + // Reset viewport — several tests shrink it for mobile/tablet checks + // and we're reusing the same page across the whole suite. + await page.setViewportSize(DEFAULT_VIEWPORT); await page.goto('/home'); await page.waitForSelector('main'); }); @@ -36,18 +48,18 @@ test.describe.serial('Sidebar', () => { await expect(poHomeChannel.navbar.btnDirectory).toBeVisible(); }); - test('should display home and directory inside a menu in smaller views', async ({ page }) => { + test('should display home and directory inside a menu in smaller views', async () => { await page.setViewportSize({ width: 1023, height: 767 }); await expect(poHomeChannel.navbar.btnMenuPages).toBeVisible(); }); - test('should display voice and omnichannel items inside a menu and sidebar toggler in mobile view', async ({ page }) => { + test('should display voice and omnichannel items inside a menu and sidebar toggler in mobile view', async () => { await page.setViewportSize({ width: 767, height: 510 }); await expect(poHomeChannel.navbar.btnVoiceAndOmnichannel).toBeVisible(); await expect(poHomeChannel.navbar.btnSidebarToggler()).toBeVisible(); }); - test('should hide everything else when navbar search is focused in mobile view', async ({ page }) => { + test('should hide everything else when navbar search is focused in mobile view', async () => { await page.setViewportSize({ width: 767, height: 510 }); await poHomeChannel.navbar.searchInput.click(); @@ -56,13 +68,13 @@ test.describe.serial('Sidebar', () => { await expect(poHomeChannel.navbar.btnVoiceAndOmnichannel).not.toBeVisible(); await expect(poHomeChannel.navbar.groupHistoryNavigation).not.toBeVisible(); }); - test('expect navbar toolbar buttons to be enabled in tablet view', async ({ page }) => { + test('expect navbar toolbar buttons to be enabled in tablet view', async () => { await page.setViewportSize({ width: 1023, height: 767 }); await expect(poHomeChannel.navbar.btnDisplay).toBeEnabled(); await expect(poHomeChannel.navbar.btnCreateNew).toBeEnabled(); }); - test('should navigate on navbar toolbar pressing tab', async ({ page }) => { + test('should navigate on navbar toolbar pressing tab', async () => { await poHomeChannel.navbar.btnHome.focus(); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); @@ -75,7 +87,7 @@ test.describe.serial('Sidebar', () => { }); test.describe('sidebar', async () => { - test('should navigate on sidebar items using arrow keys and restore focus', async ({ page }) => { + test('should navigate on sidebar items using arrow keys and restore focus', async () => { // focus should be on the next item await poHomeChannel.sidebar.channelsList.getByRole('link').first().focus(); await page.keyboard.press('ArrowDown'); @@ -87,7 +99,7 @@ test.describe.serial('Sidebar', () => { await expect(poHomeChannel.sidebar.channelsList.getByRole('link').first()).not.toBeFocused(); }); - test('should expand/collapse sidebar groups', async ({ page }) => { + test('should expand/collapse sidebar groups', async () => { await page.goto('/home'); const collapser = poHomeChannel.sidebar.firstCollapser.getByRole('button'); @@ -102,7 +114,7 @@ test.describe.serial('Sidebar', () => { expect(isExpanded).toBeTruthy(); }); - test('should expand/collapse sidebar groups with keyboard', async ({ page }) => { + test('should expand/collapse sidebar groups with keyboard', async () => { await page.goto('/home'); const collapser = poHomeChannel.sidebar.firstCollapser.getByRole('button'); @@ -123,7 +135,7 @@ test.describe.serial('Sidebar', () => { }).toPass(); }); - test('should persist collapsed/expanded groups after page reload', async ({ page }) => { + test('should persist collapsed/expanded groups after page reload', async () => { await page.goto('/home'); const collapser = poHomeChannel.sidebar.firstCollapser; @@ -150,12 +162,7 @@ test.describe.serial('Sidebar', () => { }); test.describe('embedded layout', async () => { - test.beforeEach(async ({ page }) => { - await page.goto('/home'); - await page.waitForSelector('main'); - }); - - test('should not show Navbar', async ({ page }) => { + test('should not show Navbar', async () => { await poHomeChannel.navbar.openChat(targetChannel); await expect(page.locator('role=navigation[name="header"]')).toBeVisible(); const embeddedLayoutURL = `${page.url()}?layout=embedded`; @@ -163,7 +170,7 @@ test.describe.serial('Sidebar', () => { await expect(page.locator('role=navigation[name="header"]')).not.toBeVisible(); }); - test('should show burger menu', async ({ page }) => { + test('should show burger menu', async () => { await page.goto('admin/info?layout=embedded'); await page.setViewportSize({ width: 767, height: 510 }); diff --git a/apps/meteor/tests/e2e/system-messages.spec.ts b/apps/meteor/tests/e2e/system-messages.spec.ts index bc787f6e822bc..d163a9cc27df2 100644 --- a/apps/meteor/tests/e2e/system-messages.spec.ts +++ b/apps/meteor/tests/e2e/system-messages.spec.ts @@ -1,6 +1,7 @@ import { faker } from '@faker-js/faker'; import type { Locator, Page } from '@playwright/test'; import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { BrowserContext } from 'playwright-core'; import { Users } from './fixtures/userStates'; import { HomeChannel } from './page-objects'; @@ -26,8 +27,10 @@ test.describe.serial('System Messages', () => { let poHomeChannel: HomeChannel; let user: IUser; let group: IRoom; + let page: Page; + let context: BrowserContext; - test.beforeAll(async ({ api }) => { + test.beforeAll(async ({ api, browser }) => { await expect((await setSettingValueById(api, 'Hide_System_Messages', [])).status()).toBe(200); const groupResult = await api.post('/groups.create', { name: faker.string.uuid() }); @@ -39,10 +42,13 @@ test.describe.serial('System Messages', () => { expect(result.status()).toBe(200); user = (await result.json()).user; - }); - test.beforeEach(async ({ page }) => { + context = await browser.newContext({ storageState: Users.admin.state }); + page = await context.newPage(); poHomeChannel = new HomeChannel(page); + }); + + test.beforeEach(async () => { await page.goto('/home'); if (!group?.name) { @@ -54,27 +60,29 @@ test.describe.serial('System Messages', () => { test.afterAll(async ({ api }) => { await expect((await api.post('/groups.delete', { roomId: group._id })).status()).toBe(200); + await page.close(); + await context.close(); }); - test('expect "User added" system message to be visible', async ({ page, api }) => { + test('expect "User added" system message to be visible', async ({ api }) => { await expect((await api.post('/groups.invite', { roomId: group._id, userId: user._id })).status()).toBe(200); await expect(findSysMes(page, 'au')).toBeVisible(); }); - test('expect "User added" system message to be hidden', async ({ page, api }) => { + test('expect "User added" system message to be hidden', async ({ api }) => { await expect((await setSettingValueById(api, 'Hide_System_Messages', ['au'])).status()).toBe(200); await expect(findSysMes(page, 'au')).not.toBeVisible(); }); - test('expect "User removed" system message to be visible', async ({ page, api }) => { + test('expect "User removed" system message to be visible', async ({ api }) => { await expect((await api.post('/groups.kick', { roomId: group._id, userId: user._id })).status()).toBe(200); await expect(findSysMes(page, 'ru')).toBeVisible(); }); - test('expect "User removed" system message to be hidden', async ({ page, api }) => { + test('expect "User removed" system message to be hidden', async ({ api }) => { await expect((await setSettingValueById(api, 'Hide_System_Messages', ['ru'])).status()).toBe(200); await expect(findSysMes(page, 'ru')).not.toBeVisible(); From 2c52ee2a40cd3fb50ca765c7634699c0c44abd22 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 23 Apr 2026 23:50:10 -0300 Subject: [PATCH 12/14] test(e2e): share context for message-composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12-test serial suite under user1. One test shrinks to 768x600 for the compact toolbar check — beforeEach resets to 1280x720 so the next test starts at the expected viewport. Also add user1 to targetChannel members (the channel is now shared across tests and user1 needs to post without the UI join step). --- .../meteor/tests/e2e/message-composer.spec.ts | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/meteor/tests/e2e/message-composer.spec.ts b/apps/meteor/tests/e2e/message-composer.spec.ts index 5816ec4783bfa..31fa94b622822 100644 --- a/apps/meteor/tests/e2e/message-composer.spec.ts +++ b/apps/meteor/tests/e2e/message-composer.spec.ts @@ -1,23 +1,37 @@ import { faker } from '@faker-js/faker'; +import type { BrowserContext, Page } from 'playwright-core'; import { Users } from './fixtures/userStates'; import { HomeChannel } from './page-objects'; import { createTargetChannel } from './utils'; import { expect, test } from './utils/test'; +const DEFAULT_VIEWPORT = { width: 1280, height: 720 }; + test.use({ storageState: Users.user1.state }); test.describe.serial('message-composer', () => { let poHomeChannel: HomeChannel; let targetChannel: string; + let page: Page; + let context: BrowserContext; - test.beforeAll(async ({ api }) => { - targetChannel = await createTargetChannel(api); + test.beforeAll(async ({ api, browser }) => { + targetChannel = await createTargetChannel(api, { members: ['user1'] }); + context = await browser.newContext({ storageState: Users.user1.state, viewport: DEFAULT_VIEWPORT }); + page = await context.newPage(); + poHomeChannel = new HomeChannel(page); }); - test.beforeEach(async ({ page }) => { - poHomeChannel = new HomeChannel(page); + test.afterAll(async () => { + await page.close(); + await context.close(); + }); + test.beforeEach(async () => { + // One test shrinks the viewport to 768x600 — make sure we start at the + // default each time. + await page.setViewportSize(DEFAULT_VIEWPORT); await page.goto('/home'); }); @@ -28,14 +42,14 @@ test.describe.serial('message-composer', () => { await expect(poHomeChannel.composer.allPrimaryActions).toHaveCount(12); }); - test('should have only the main formatter and the main action', async ({ page }) => { + test('should have only the main formatter and the main action', async () => { await page.setViewportSize({ width: 768, height: 600 }); await poHomeChannel.navbar.openChat(targetChannel); await expect(poHomeChannel.composer.allPrimaryActions).toHaveCount(6); }); - test('should navigate on toolbar using arrow keys', async ({ page }) => { + test('should navigate on toolbar using arrow keys', async () => { await poHomeChannel.navbar.openChat(targetChannel); await page.keyboard.press('Tab'); @@ -47,7 +61,7 @@ test.describe.serial('message-composer', () => { await expect(poHomeChannel.composer.btnBoldFormatter).toBeFocused(); }); - test('should move the focus away from toolbar using tab key', async ({ page }) => { + test('should move the focus away from toolbar using tab key', async () => { await poHomeChannel.navbar.openChat(targetChannel); await page.keyboard.press('Tab'); @@ -56,7 +70,7 @@ test.describe.serial('message-composer', () => { await expect(poHomeChannel.composer.btnEmoji).not.toBeFocused(); }); - test('should add a link to the selected text', async ({ page }) => { + test('should add a link to the selected text', async () => { const url = faker.internet.url(); await poHomeChannel.navbar.openChat(targetChannel); @@ -70,7 +84,7 @@ test.describe.serial('message-composer', () => { await expect(poHomeChannel.composer.inputMessage).toHaveValue(`[hello composer](${url})`); }); - test('should select popup item and not send the message when pressing enter', async ({ page }) => { + test('should select popup item and not send the message when pressing enter', async () => { await poHomeChannel.navbar.openChat(targetChannel); await poHomeChannel.content.sendMessage('hello composer'); @@ -105,7 +119,7 @@ test.describe.serial('message-composer', () => { }); }); - test('should list popup items correctly', async ({ page }) => { + test('should list popup items correctly', async () => { await poHomeChannel.navbar.openChat(targetChannel); await poHomeChannel.content.sendMessage('hello composer'); @@ -116,7 +130,7 @@ test.describe.serial('message-composer', () => { }); }); - test('should close mention popup when canceling a message edit via "Cancel" button', async ({ page }) => { + test('should close mention popup when canceling a message edit via "Cancel" button', async () => { await poHomeChannel.navbar.openChat(targetChannel); await poHomeChannel.content.sendMessage('hello composer'); @@ -144,7 +158,7 @@ test.describe.serial('message-composer', () => { }); }); - test('should close mention popup when canceling a message edit via keyboard', async ({ page }) => { + test('should close mention popup when canceling a message edit via keyboard', async () => { await poHomeChannel.navbar.openChat(targetChannel); await poHomeChannel.content.sendMessage('hello composer'); @@ -189,7 +203,7 @@ test.describe.serial('message-composer', () => { await expect(poHomeChannel.audioRecorder).not.toBeVisible(); }); - test('should attach file to the composer when clicking on "Finish recording"', async ({ page }) => { + test('should attach file to the composer when clicking on "Finish recording"', async () => { await poHomeChannel.navbar.openChat(targetChannel); await poHomeChannel.composer.btnAudioMessage.click(); await expect(poHomeChannel.audioRecorder).toBeVisible(); From ce95db99ee862a1ffa6c6ab2f86f637beedab38f Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 24 Apr 2026 10:05:34 -0300 Subject: [PATCH 13/14] revert: roll back account-profile pattern 2 migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shard 1/4 of the UI CI job hit a flaky "Target page has been closed" on the Avatar > "should show inline error if url does not point to an image" test after 60s timeout. Reproduces locally when the dev meteor server has any hiccup. The account-profile form state does not fully reset across the shared page the way page.goto('/account/ profile') suggests — leaving the shared-context migration for a follow-up that understands the interaction with the avatar save pipeline. The rest of the Phase 2 shared-context batches (report-message, messaging, threads, image-gallery, account-security, admin-room, emojis, file-upload, jump-to-thread-message, email-inboxes, messaging-scroll-to-bottom, imports, system-messages, settings- assets, sidebar, message-composer) remain migrated. --- apps/meteor/tests/e2e/account-profile.spec.ts | 40 ++++++------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/apps/meteor/tests/e2e/account-profile.spec.ts b/apps/meteor/tests/e2e/account-profile.spec.ts index 771906949cf47..7ee63abe1cdd2 100644 --- a/apps/meteor/tests/e2e/account-profile.spec.ts +++ b/apps/meteor/tests/e2e/account-profile.spec.ts @@ -1,6 +1,4 @@ -import AxeBuilder from '@axe-core/playwright'; import { faker } from '@faker-js/faker'; -import type { BrowserContext, Page } from 'playwright-core'; import { Users } from './fixtures/userStates'; import { HomeChannel, AccountProfile } from './page-objects'; @@ -11,31 +9,17 @@ test.use({ storageState: Users.user3.state }); test.describe.serial('settings-account-profile', () => { let poHomeChannel: HomeChannel; let poAccountProfile: AccountProfile; - let page: Page; - let context: BrowserContext; const token = faker.string.alpha(10); - const axe = () => - new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) - .include('body') - .disableRules(['aria-hidden-focus', 'nested-interactive']); - - test.beforeAll(async ({ browser }) => { - context = await browser.newContext({ storageState: Users.user3.state }); - page = await context.newPage(); + + test.beforeEach(async ({ page }) => { poHomeChannel = new HomeChannel(page); poAccountProfile = new AccountProfile(page); }); - test.afterAll(async () => { - await page.close(); - await context.close(); - }); - // FIXME: solve test intermitencies test.describe('Profile', () => { - test.beforeEach(async () => { + test.beforeEach(async ({ page }) => { await page.goto('/account/profile'); }); @@ -90,8 +74,10 @@ test.describe.serial('settings-account-profile', () => { }); }); - test('Personal Access Tokens', async () => { - await Promise.all([page.waitForResponse('**/api/v1/users.getPersonalAccessTokens'), page.goto('/account/tokens')]); + test('Personal Access Tokens', async ({ page }) => { + const response = page.waitForResponse('**/api/v1/users.getPersonalAccessTokens'); + await page.goto('/account/tokens'); + await response; await test.step('should show empty personal access tokens table', async () => { await expect(poAccountProfile.tokensTableEmpty).toBeVisible(); @@ -131,28 +117,28 @@ test.describe.serial('settings-account-profile', () => { }); test.describe('Omnichannel', () => { - test('should not have any accessibility violations', async () => { + test('should not have any accessibility violations', async ({ page, makeAxeBuilder }) => { await page.goto('/account/omnichannel'); - const results = await axe().analyze(); + const results = await makeAxeBuilder().analyze(); expect(results.violations).toEqual([]); }); }); test.describe('Feature Preview', () => { - test('should not have any accessibility violations', async () => { + test('should not have any accessibility violations', async ({ page, makeAxeBuilder }) => { await page.goto('/account/feature-preview'); - const results = await axe().analyze(); + const results = await makeAxeBuilder().analyze(); expect(results.violations).toEqual([]); }); }); test.describe('Accessibility & Appearance', () => { - test('should not have any accessibility violations', async () => { + test('should not have any accessibility violations', async ({ page, makeAxeBuilder }) => { await page.goto('/account/accessibility-and-appearance'); - const results = await axe().analyze(); + const results = await makeAxeBuilder().analyze(); expect(results.violations).toEqual([]); }); }); From be99397ecea18938132bd0af36641af32e41dff1 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 24 Apr 2026 13:02:38 -0300 Subject: [PATCH 14/14] revert(ci): drop the scheduled timing-guardrail workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulling it out of the PR on request. JSON reporter stays in playwright.config.ts and e2e-timing-report.mts / the local compare helper still let a human inspect any downloaded CI artifact — enough for this PR. The scheduled half can ship separately when the pattern 2 rollout is further along. --- .github/workflows/e2e-timing-guardrail.yml | 89 --------------------- docs/proposals/e2e-performance-migration.md | 2 +- 2 files changed, 1 insertion(+), 90 deletions(-) delete mode 100644 .github/workflows/e2e-timing-guardrail.yml diff --git a/.github/workflows/e2e-timing-guardrail.yml b/.github/workflows/e2e-timing-guardrail.yml deleted file mode 100644 index ec0c27a59fd37..0000000000000 --- a/.github/workflows/e2e-timing-guardrail.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: E2E Timing Guardrail - -on: - schedule: - # Mondays 09:00 UTC — the week's signal, before most merges land - - cron: '0 9 * * 1' - workflow_dispatch: - inputs: - threshold_ms: - description: 'p50 threshold per test, in ms' - required: false - default: '3000' - -permissions: - contents: read - actions: read - -jobs: - report: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v5 - with: - ref: develop - - - uses: actions/setup-node@v5 - with: - node-version: 22 - - - name: Download the latest successful e2e trace artifact from develop - id: dl - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - run_id=$(gh run list \ - --branch develop \ - --workflow ci.yml \ - --status success \ - --limit 1 \ - --json databaseId \ - --jq '.[0].databaseId') - if [ -z "${run_id:-}" ]; then - echo "No successful ci.yml run on develop — skipping" >&2 - echo "has_artifact=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - mkdir -p artifacts - # Artifact name from ci-test-e2e.yml: playwright-test-trace--- - if ! gh run download "$run_id" \ - --pattern 'playwright-test-trace-*' \ - --dir artifacts; then - echo "No playwright-test-trace-* artifact in run $run_id — skipping" >&2 - echo "has_artifact=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "has_artifact=true" >> "$GITHUB_OUTPUT" - echo "run_id=$run_id" >> "$GITHUB_OUTPUT" - - - name: Generate timing report - if: steps.dl.outputs.has_artifact == 'true' - env: - THRESHOLD: ${{ inputs.threshold_ms || '3000' }} - RUN_ID: ${{ steps.dl.outputs.run_id }} - run: | - set -euo pipefail - # Each shard produces one results.json — concatenate their markdown - # outputs (threshold-filter runs per shard, dedup is cheap post-hoc). - { - echo "# E2E timing guardrail" - echo "" - echo "Source: [ci.yml run $RUN_ID](https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID) on \`develop\`." - echo "" - } >> "$GITHUB_STEP_SUMMARY" - found=0 - for json in artifacts/*/results.json; do - [ -f "$json" ] || continue - found=$((found + 1)) - shard=$(basename "$(dirname "$json")") - { - echo "## \`$shard\`" - echo "" - node --experimental-strip-types apps/meteor/tests/e2e/scripts/e2e-timing-report.mts "$json" "$THRESHOLD" - echo "" - } >> "$GITHUB_STEP_SUMMARY" - done - if [ "$found" -eq 0 ]; then - echo "No results.json found in downloaded artifacts — the workflow may predate JSON-reporter rollout." >> "$GITHUB_STEP_SUMMARY" - fi diff --git a/docs/proposals/e2e-performance-migration.md b/docs/proposals/e2e-performance-migration.md index 1fb3e8b0b3a86..479db88231a49 100644 --- a/docs/proposals/e2e-performance-migration.md +++ b/docs/proposals/e2e-performance-migration.md @@ -117,7 +117,7 @@ Prevents regression after Phase 2 completes. Can land in parallel with Phase 2. - Uses `poHomeChannel.content.sendMessage` inside `test.beforeEach` or `test.beforeAll`. - Declares `test.describe.serial` together with `beforeEach(async ({ page }) => { await page.goto(...) })`. Both are strong signals of missed Pattern 1 / Pattern 2 opportunities. -3. **Timing guardrail** — in place. `playwright.config.ts` now emits a JSON report; [`apps/meteor/tests/e2e/scripts/e2e-timing-report.mts`](../../apps/meteor/tests/e2e/scripts/e2e-timing-report.mts) turns it into a p50>3s/test table; [`.github/workflows/e2e-timing-guardrail.yml`](../../.github/workflows/e2e-timing-guardrail.yml) runs weekly (Mondays 09:00 UTC), pulls the latest successful `ci.yml` trace artifact from `develop` and posts the table as the run summary. +3. **Timing guardrail** — partial. `playwright.config.ts` emits a JSON report alongside the existing `playwright-test-trace-*` artifact, and [`apps/meteor/tests/e2e/scripts/e2e-timing-report.mts`](../../apps/meteor/tests/e2e/scripts/e2e-timing-report.mts) turns it into a p50>3s/test markdown table. Point it at a downloaded run (`gh run download --pattern 'playwright-test-trace-*'`) to flag recurring offenders. The scheduled-workflow half is deferred — we can wire it up once the pattern 2 rollout is further along. ## Picking up the work