Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 94 additions & 64 deletions apps/meteor/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,72 +1,102 @@
import type { PlaywrightTestConfig } from '@playwright/test';
import { defineConfig, type Project, type ReporterDescription } from '@playwright/test';

import * as constants from './tests/e2e/config/constants';

export default {
globalSetup: require.resolve('./tests/e2e/config/global-setup.ts'),
use: {
channel: 'chromium',
headless: true,
ignoreHTTPSErrors: true,
trace: 'retain-on-failure',
baseURL: constants.BASE_URL,
screenshot: process.env.CI ? 'off' : 'only-on-failure',
video: process.env.CI ? 'off' : 'retain-on-failure',
launchOptions: {
// force GPU hardware acceleration
// (even in headless mode)
args: ['--use-gl=egl', '--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'],
const reporter: ReporterDescription[] = [['list']];

if (process.env.REPORTER_ROCKETCHAT_REPORT === 'true') {
reporter.push([
'./reporters/rocketchat.ts',
{
url: process.env.REPORTER_ROCKETCHAT_URL,
apiKey: process.env.REPORTER_ROCKETCHAT_API_KEY,
branch: process.env.REPORTER_ROCKETCHAT_BRANCH,
run: Number(process.env.REPORTER_ROCKETCHAT_RUN),
draft: process.env.REPORTER_ROCKETCHAT_DRAFT === 'true',
headSha: process.env.REPORTER_ROCKETCHAT_HEAD_SHA,
},
]);

reporter.push([
'./reporters/jira.ts',
{
url: `https://rocketchat.atlassian.net`,
apiKey: process.env.REPORTER_JIRA_ROCKETCHAT_API_KEY ?? process.env.JIRA_TOKEN,
branch: process.env.REPORTER_ROCKETCHAT_BRANCH,
run: Number(process.env.REPORTER_ROCKETCHAT_RUN),
headSha: process.env.REPORTER_ROCKETCHAT_HEAD_SHA,
author: process.env.REPORTER_ROCKETCHAT_AUTHOR,
run_url: process.env.REPORTER_ROCKETCHAT_RUN_URL,
pr: Number(process.env.REPORTER_ROCKETCHAT_PR),
draft: process.env.REPORTER_ROCKETCHAT_DRAFT === 'true',
},
permissions: ['microphone'],
]);
}

reporter.push([
'playwright-qase-reporter',
{
apiToken: `${process.env.QASE_API_TOKEN}`,
rootSuiteTitle: 'Rocket.chat automation',
projectCode: 'RC',
runComplete: true,
basePath: 'https://api.qase.io/v1',
logging: true,
uploadAttachments: false,
environmentId: '1',
},
outputDir: 'tests/e2e/.playwright',
reporter: [
['list'],
process.env.REPORTER_ROCKETCHAT_REPORT === 'true' && [
'./reporters/rocketchat.ts',
{
url: process.env.REPORTER_ROCKETCHAT_URL,
apiKey: process.env.REPORTER_ROCKETCHAT_API_KEY,
branch: process.env.REPORTER_ROCKETCHAT_BRANCH,
run: Number(process.env.REPORTER_ROCKETCHAT_RUN),
draft: process.env.REPORTER_ROCKETCHAT_DRAFT === 'true',
headSha: process.env.REPORTER_ROCKETCHAT_HEAD_SHA,
},
],
process.env.REPORTER_ROCKETCHAT_REPORT === 'true' && [
'./reporters/jira.ts',
{
url: `https://rocketchat.atlassian.net`,
apiKey: process.env.REPORTER_JIRA_ROCKETCHAT_API_KEY ?? process.env.JIRA_TOKEN,
branch: process.env.REPORTER_ROCKETCHAT_BRANCH,
run: Number(process.env.REPORTER_ROCKETCHAT_RUN),
headSha: process.env.REPORTER_ROCKETCHAT_HEAD_SHA,
author: process.env.REPORTER_ROCKETCHAT_AUTHOR,
run_url: process.env.REPORTER_ROCKETCHAT_RUN_URL,
pr: Number(process.env.REPORTER_ROCKETCHAT_PR),
draft: process.env.REPORTER_ROCKETCHAT_DRAFT === 'true',
},
],
[
'playwright-qase-reporter',
{
apiToken: `${process.env.QASE_API_TOKEN}`,
rootSuiteTitle: 'Rocket.chat automation',
projectCode: 'RC',
runComplete: true,
basePath: 'https://api.qase.io/v1',
logging: true,
uploadAttachments: false,
environmentId: '1',
]);

const projects: Project[] = [
{
name: 'e2e-tests',
use: {
channel: 'chromium',
headless: true,
ignoreHTTPSErrors: true,
trace: 'retain-on-failure',
baseURL: constants.BASE_URL,
screenshot: process.env.CI ? 'off' : 'only-on-failure',
video: process.env.CI ? 'off' : 'retain-on-failure',
launchOptions: {
// force GPU hardware acceleration
// (even in headless mode)
args: ['--use-gl=egl', '--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'],
},
],
].filter(Boolean) as unknown as PlaywrightTestConfig['reporter'],
testDir: 'tests/e2e',
testIgnore: 'tests/e2e/federation/**',
workers: 1,
timeout: 60 * 1000,
permissions: ['microphone'],
},
outputDir: 'tests/e2e/.playwright',
testDir: 'tests/e2e',
testIgnore: ['tests/e2e/federation/**', 'tests/e2e/load/**'],
workers: 1,
timeout: 60 * 1000,
// Retry on CI only.
retries: parseInt(String(process.env.PLAYWRIGHT_RETRIES)) || 0,
},
{
name: 'load-tests',
use: {
channel: 'chromium',
headless: true,
ignoreHTTPSErrors: true,
trace: 'retain-on-failure',
baseURL: constants.BASE_URL,
},
outputDir: 'tests/e2e/load/.playwright',
testDir: 'tests/e2e/load',
workers: 1,
timeout: 30 * 60 * 1000,
// Retry on CI only.
retries: parseInt(String(process.env.PLAYWRIGHT_RETRIES)) || 0,
},
];

export default defineConfig({
globalSetup: require.resolve('./tests/e2e/config/global-setup.ts'),
projects,

reporter,

globalTimeout: (process.env.IS_EE === 'true' ? 50 : 40) * 60 * 1000,
maxFailures: process.env.CI ? 5 : undefined,
// Retry on CI only.
retries: parseInt(String(process.env.PLAYWRIGHT_RETRIES)) || 0,
} as PlaywrightTestConfig;
});
1 change: 1 addition & 0 deletions apps/meteor/tests/e2e/load/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.playwright
45 changes: 45 additions & 0 deletions apps/meteor/tests/e2e/load/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export type LoadConfig = {
users: number;
iterations: number;
rampDelayMs: number;
pauseBetweenIterationsMs: number;
statusMessages: string[];
messageTemplate: string;
};

const toNumber = (value: string | undefined, fallback: number, minimum = 0): number => {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed >= minimum) {
return parsed;
}
return fallback;
};

const toStringArray = (value: string | undefined, fallback: string[]): string[] => {
if (!value) {
return fallback;
}
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
};

export const getLoadConfig = (overrides: Partial<LoadConfig> = {}): LoadConfig => {
const users = Math.max(1, toNumber(process.env.LOAD_USERS, 5, 1));
const iterations = Math.max(1, toNumber(process.env.LOAD_ITERATIONS, 3, 1));
const rampDelayMs = toNumber(process.env.LOAD_RAMP_MS, 30, 0);
const pauseBetweenIterationsMs = toNumber(process.env.LOAD_ITERATION_PAUSE_MS, 50, 0);
const statusMessages = toStringArray(process.env.LOAD_STATUS_MESSAGES, ['Heads down', 'In a meeting', 'Back soon']);
const messageTemplate = process.env.LOAD_MESSAGE_TEMPLATE ?? 'playwright-load-message';

return {
users,
iterations,
rampDelayMs,
pauseBetweenIterationsMs,
statusMessages,
messageTemplate,
...overrides,
};
};
181 changes: 181 additions & 0 deletions apps/meteor/tests/e2e/load/harness.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/* eslint-disable no-await-in-loop */
import type { BrowserContext, Page } from '@playwright/test';

import { DEFAULT_USER_CREDENTIALS } from '../config/constants';
import { Registration, HomeChannel } from '../page-objects';
import { getLoadConfig } from './config';
import { createTargetChannelAndReturnFullRoom, deleteRoom } from '../utils/create-target-channel';
import { DatabaseClient } from '../utils/db';
import { test, expect } from '../utils/test';
import { createTestUsers, type ITestUser } from '../utils/user-helpers';

const config = getLoadConfig();

test.describe('Playwright Load Harness', () => {
let channelId: string;
let channelName: string;
let users: ITestUser[] = [];
let db: DatabaseClient;

test.beforeAll(async () => {
db = await DatabaseClient.connect();
});

test.afterAll(async () => {
await db.close();
});

test.beforeAll(async ({ api }) => {
const { channel } = await createTargetChannelAndReturnFullRoom(api);
channelId = channel._id;
channelName = channel.name ?? 'load-channel';

users = await createTestUsers(api, config.users);

await Promise.all(
users.map((user) =>
api.post('/channels.invite', {
roomId: channelId,
userId: user.data._id,
}),
),
);
});

test.afterAll(async ({ api }) => {
const results = await Promise.allSettled(users.map((user) => user.delete()));
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
if (failures.length) {
console.error('Some test users were not deleted successfully:');
for (const failure of failures) {
console.error(failure.reason);
}
}
if (channelId) {
await deleteRoom(api, channelId);
}
});

test(`runs load scenarios for ${config.users} users`, async ({ browser }) => {
const results = await Promise.allSettled(
users.map(async (user, index) =>
runUserScenario({
context: await browser.newContext(),
user,
channelName,
index,
config,
db,
}),
),
);

const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
if (failures.length) {
throw failures[0].reason;
}

const successful = results
.filter((result): result is PromiseFulfilledResult<ScenarioSummary> => result.status === 'fulfilled')
.map((result) => result.value);

console.log(
JSON.stringify(
{
config,
results: successful,
},
null,
2,
),
);

expect(successful).toHaveLength(users.length);
});
});

type ScenarioSummary = {
username: string;
iterations: number;
durations: number[];
};

type ScenarioOptions = {
context: BrowserContext;
user: ITestUser;
channelName: string;
index: number;
config: ReturnType<typeof getLoadConfig>;
db: DatabaseClient;
};

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

async function runUserScenario({ context, user, channelName, index, config, db }: ScenarioOptions): Promise<ScenarioSummary> {
if (config.rampDelayMs) {
await delay(index * config.rampDelayMs);
}

const page = await context.newPage();
const registration = new Registration(page);
const home = new HomeChannel(page);
let userSession;

try {
await home.goto();
await loginUser(page, registration, user.data.username);
await home.sidenav.openChat(channelName);

const durations: number[] = [];
userSession = await db.usersSessions.findOneById(user.data._id);

for (let iteration = 0; iteration < config.iterations; iteration += 1) {
const iterationStart = Date.now();
const messageContent = `${config.messageTemplate}::${user.data.username}::${iteration}`;

await home.content.sendMessage(messageContent);

durations.push(Date.now() - iterationStart);

if (config.pauseBetweenIterationsMs) {
await page.waitForTimeout(config.pauseBetweenIterationsMs);
}
}

return {
username: user.data.username,
iterations: config.iterations,
durations,
};
} finally {
try {
await context.close();
} catch (error) {
console.error(`Error closing context for user ${user.data.username}:`, error);
}

if (userSession?.connections?.length) {
await db.usersSessions.updateOne(
{ _id: user.data._id },
{
$set: {
connections: userSession.connections.map((connection) => {
return {
...connection,
_createdAt: new Date(connection._createdAt.getTime() - 300_000),
_updatedAt: new Date(connection._updatedAt.getTime() - 300_000),
};
}),
},
},
);
}
}
}

async function loginUser(page: Page, registration: Registration, username: string) {
await registration.username.fill(username);
await registration.inputPassword.fill(DEFAULT_USER_CREDENTIALS.password);
await registration.btnLogin.click();
await expect(page.getByRole('button', { name: 'User menu' })).toBeVisible();
}
Loading
Loading