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/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/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/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(); 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/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/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/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 () => { 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/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; 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/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(); 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); diff --git a/apps/meteor/tests/e2e/messaging.spec.ts b/apps/meteor/tests/e2e/messaging.spec.ts index f17720d3ba56b..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 { createTargetChannel, deleteChannel } from './utils'; +import { createTargetChannelAndReturnFullRoom, deleteRoom, joinChannelAsUser, sendMessage } from './utils'; import { expect, test } from './utils/test'; test.use({ storageState: Users.user1.state }); @@ -12,9 +12,19 @@ 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; + + // 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 }); }); test.beforeEach(async ({ page }) => { @@ -23,16 +33,13 @@ 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 poHomeChannel.composer.inputMessage.click(); await test.step('move focus to the second message', async () => { await page.keyboard.press('Shift+Tab'); @@ -163,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/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/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/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/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/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(); 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(); }); 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..a44593dd2cfcb --- /dev/null +++ b/apps/meteor/tests/e2e/utils/messages.ts @@ -0,0 +1,73 @@ +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({ + extraHTTPHeaders: { + 'X-Auth-Token': options.asUser.data.loginToken, + 'X-User-Id': options.asUser.data._id, + }, + }); + try { + 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) { + 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..845ad7a129e92 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/rooms.ts @@ -0,0 +1,61 @@ +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 }); +} + +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 }); +} + +// 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(); + } +} 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 }; +} diff --git a/docs/proposals/e2e-migration-triage.md b/docs/proposals/e2e-migration-triage.md new file mode 100644 index 0000000000000..595cb9fe7fe34 --- /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: 5 + +## 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 | +| --- | :-: | --: | --: | --: | +| `feature-preview.spec.ts` | yes | 1 | — | — | +| `quote-messages.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 | — | — | +| `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 | — | — | +| `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 | — | — | +| `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 | — | — | +| `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 | — | — | +| `report-message.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 | — | — | +| `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 | — | — | +| `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 new file mode 100644 index 0000000000000..479db88231a49 --- /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: [`docs/proposals/e2e-migration-triage.md`](./e2e-migration-triage.md) — markdown table 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 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 + +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** — 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** — 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 + +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?