fix: multiple bug fixes
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 6m54s
Queue Release Build / build-windows (push) Successful in 16m6s
Queue Release Build / build-linux (push) Successful in 30m58s
Queue Release Build / finalize (push) Successful in 44s
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 6m54s
Queue Release Build / build-windows (push) Successful in 16m6s
Queue Release Build / build-linux (push) Successful in 30m58s
Queue Release Build / finalize (push) Successful in 44s
isolated users, db backup, weird disconnect issues for long voice sessions,
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { type Page, type Locator } from '@playwright/test';
|
||||
|
||||
export class LoginPage {
|
||||
readonly form: Locator;
|
||||
readonly usernameInput: Locator;
|
||||
readonly passwordInput: Locator;
|
||||
readonly serverSelect: Locator;
|
||||
@@ -9,12 +10,13 @@ export class LoginPage {
|
||||
readonly registerLink: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]').first();
|
||||
this.usernameInput = page.locator('#login-username');
|
||||
this.passwordInput = page.locator('#login-password');
|
||||
this.serverSelect = page.locator('#login-server');
|
||||
this.submitButton = page.getByRole('button', { name: 'Login' });
|
||||
this.submitButton = this.form.getByRole('button', { name: 'Login' });
|
||||
this.errorText = page.locator('.text-destructive');
|
||||
this.registerLink = page.getByRole('button', { name: 'Register' });
|
||||
this.registerLink = this.form.getByRole('button', { name: 'Register' });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
|
||||
@@ -43,8 +43,11 @@ export class RegisterPage {
|
||||
|
||||
async register(username: string, displayName: string, password: string) {
|
||||
await this.usernameInput.fill(username);
|
||||
await expect(this.usernameInput).toHaveValue(username);
|
||||
await this.displayNameInput.fill(displayName);
|
||||
await expect(this.displayNameInput).toHaveValue(displayName);
|
||||
await this.passwordInput.fill(password);
|
||||
await expect(this.passwordInput).toHaveValue(password);
|
||||
await this.submitButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
269
e2e/tests/auth/user-session-data-isolation.spec.ts
Normal file
269
e2e/tests/auth/user-session-data-isolation.spec.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
chromium,
|
||||
type BrowserContext,
|
||||
type Page
|
||||
} from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
import { LoginPage } from '../../pages/login.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
|
||||
interface TestUser {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface PersistentClient {
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
userDataDir: string;
|
||||
}
|
||||
|
||||
const CLIENT_LAUNCH_ARGS = [
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--use-fake-ui-for-media-stream'
|
||||
];
|
||||
|
||||
test.describe('User session data isolation', () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test('preserves a user saved rooms and local history across app restarts', async ({ testServer }) => {
|
||||
const suffix = uniqueName('persist');
|
||||
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-persist-'));
|
||||
const alice: TestUser = {
|
||||
username: `alice_${suffix}`,
|
||||
displayName: 'Alice',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const aliceServerName = `Alice Session Server ${suffix}`;
|
||||
const aliceMessage = `Alice persisted message ${suffix}`;
|
||||
let client: PersistentClient | null = null;
|
||||
|
||||
try {
|
||||
client = await launchPersistentClient(userDataDir, testServer.port);
|
||||
|
||||
await test.step('Alice registers and creates local chat history', async () => {
|
||||
await registerUser(client.page, alice);
|
||||
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
|
||||
});
|
||||
|
||||
await test.step('Alice sees the same saved room and message after a full restart', async () => {
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expect(client.page).not.toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
});
|
||||
} finally {
|
||||
await closePersistentClient(client);
|
||||
await rm(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('gives a new user a blank slate and restores only that user local data after account switches', async ({ testServer }) => {
|
||||
const suffix = uniqueName('isolation');
|
||||
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-isolation-'));
|
||||
const alice: TestUser = {
|
||||
username: `alice_${suffix}`,
|
||||
displayName: 'Alice',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const bob: TestUser = {
|
||||
username: `bob_${suffix}`,
|
||||
displayName: 'Bob',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const aliceServerName = `Alice Private Server ${suffix}`;
|
||||
const bobServerName = `Bob Private Server ${suffix}`;
|
||||
const aliceMessage = `Alice history ${suffix}`;
|
||||
const bobMessage = `Bob history ${suffix}`;
|
||||
let client: PersistentClient | null = null;
|
||||
|
||||
try {
|
||||
client = await launchPersistentClient(userDataDir, testServer.port);
|
||||
|
||||
await test.step('Alice creates persisted local data and verifies it survives a restart', async () => {
|
||||
await registerUser(client.page, alice);
|
||||
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
|
||||
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
});
|
||||
|
||||
await test.step('Bob starts from a blank slate in the same browser profile', async () => {
|
||||
await logoutUser(client.page);
|
||||
await registerUser(client.page, bob);
|
||||
await expectBlankSlate(client.page, [aliceServerName]);
|
||||
});
|
||||
|
||||
await test.step('Bob gets only his own saved room and history after a restart', async () => {
|
||||
await createServerAndSendMessage(client.page, bobServerName, bobMessage);
|
||||
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expectSavedRoomAndHistory(client.page, bobServerName, bobMessage);
|
||||
await expectSavedRoomHidden(client.page, aliceServerName);
|
||||
});
|
||||
|
||||
await test.step('When Alice logs back in she sees only Alice local data, not Bob data', async () => {
|
||||
await logoutUser(client.page);
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await loginUser(client.page, alice);
|
||||
|
||||
await expectSavedRoomVisible(client.page, aliceServerName);
|
||||
await expectSavedRoomHidden(client.page, bobServerName);
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
});
|
||||
} finally {
|
||||
await closePersistentClient(client);
|
||||
await rm(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function launchPersistentClient(userDataDir: string, testServerPort: number): Promise<PersistentClient> {
|
||||
const context = await chromium.launchPersistentContext(userDataDir, {
|
||||
args: CLIENT_LAUNCH_ARGS,
|
||||
baseURL: 'http://localhost:4200',
|
||||
permissions: ['microphone', 'camera']
|
||||
});
|
||||
|
||||
await installTestServerEndpoint(context, testServerPort);
|
||||
|
||||
const page = context.pages()[0] ?? await context.newPage();
|
||||
|
||||
return {
|
||||
context,
|
||||
page,
|
||||
userDataDir
|
||||
};
|
||||
}
|
||||
|
||||
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
|
||||
await client.context.close();
|
||||
|
||||
const restartedClient = await launchPersistentClient(client.userDataDir, testServerPort);
|
||||
|
||||
client.context = restartedClient.context;
|
||||
client.page = restartedClient.page;
|
||||
}
|
||||
|
||||
async function closePersistentClient(client: PersistentClient | null): Promise<void> {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.context.close().catch(() => {});
|
||||
}
|
||||
|
||||
async function openApp(page: Page): Promise<void> {
|
||||
await retryTransientNavigation(() => page.goto('/', { waitUntil: 'domcontentloaded' }));
|
||||
}
|
||||
|
||||
async function registerUser(page: Page, user: TestUser): Promise<void> {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await retryTransientNavigation(() => registerPage.goto());
|
||||
await registerPage.register(user.username, user.displayName, user.password);
|
||||
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function loginUser(page: Page, user: TestUser): Promise<void> {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await retryTransientNavigation(() => loginPage.goto());
|
||||
await loginPage.login(user.username, user.password);
|
||||
await expect(page).toHaveURL(/\/(search|room)(\/|$)/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function logoutUser(page: Page): Promise<void> {
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await expect(menuButton).toBeVisible({ timeout: 10_000 });
|
||||
await menuButton.click();
|
||||
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
|
||||
await logoutButton.click();
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise<void> {
|
||||
const searchPage = new ServerSearchPage(page);
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
|
||||
await searchPage.createServer(serverName, {
|
||||
description: `User session isolation coverage for ${serverName}`
|
||||
});
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
await messagesPage.sendMessage(messageText);
|
||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> {
|
||||
const roomButton = getSavedRoomButton(page, roomName);
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
|
||||
await expect(roomButton).toBeVisible({ timeout: 20_000 });
|
||||
await roomButton.click();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
|
||||
const searchPage = new ServerSearchPage(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
await expect(searchPage.createServerButton).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
for (const roomName of hiddenRoomNames) {
|
||||
await expectSavedRoomHidden(page, roomName);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
|
||||
await expect(getSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
|
||||
await expect(getSavedRoomButton(page, roomName)).toHaveCount(0);
|
||||
}
|
||||
|
||||
function getSavedRoomButton(page: Page, roomName: string) {
|
||||
return page.locator(`button[title="${roomName}"]`).first();
|
||||
}
|
||||
|
||||
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
return await navigate();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');
|
||||
|
||||
if (!isTransientNavigationError || attempt === attempts) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(`Navigation failed after ${attempts} attempts`);
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -18,6 +18,81 @@ const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||
test.describe('Chat messaging features', () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test('shows per-server channel lists on first saved-server click', async ({ createClient }) => {
|
||||
const scenario = await createSingleClientChatScenario(createClient);
|
||||
const alphaServerName = `Alpha Server ${uniqueName('rail')}`;
|
||||
const betaServerName = `Beta Server ${uniqueName('rail')}`;
|
||||
const alphaChannelName = uniqueName('alpha-updates');
|
||||
const betaChannelName = uniqueName('beta-plans');
|
||||
const channelsPanel = scenario.room.channelsSidePanel;
|
||||
|
||||
await test.step('Create first saved server with a unique text channel', async () => {
|
||||
await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail switch alpha server');
|
||||
await scenario.room.ensureTextChannelExists(alphaChannelName);
|
||||
await expect(
|
||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Create second saved server with a different text channel', async () => {
|
||||
await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail switch beta server');
|
||||
await scenario.room.ensureTextChannelExists(betaChannelName);
|
||||
await expect(
|
||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Opening first server once restores only its channels', async () => {
|
||||
await openSavedRoomByName(scenario.client.page, alphaServerName);
|
||||
await expect(
|
||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await expect(
|
||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Opening second server once restores only its channels', async () => {
|
||||
await openSavedRoomByName(scenario.client.page, betaServerName);
|
||||
await expect(
|
||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await expect(
|
||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('shows local room history on first saved-server click', async ({ createClient }) => {
|
||||
const scenario = await createSingleClientChatScenario(createClient);
|
||||
const alphaServerName = `History Alpha ${uniqueName('rail')}`;
|
||||
const betaServerName = `History Beta ${uniqueName('rail')}`;
|
||||
const alphaMessage = `Alpha history message ${uniqueName('msg')}`;
|
||||
const betaMessage = `Beta history message ${uniqueName('msg')}`;
|
||||
|
||||
await test.step('Create first server and send a local message', async () => {
|
||||
await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail history alpha server');
|
||||
await scenario.messages.sendMessage(alphaMessage);
|
||||
await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Create second server and send a different local message', async () => {
|
||||
await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail history beta server');
|
||||
await scenario.messages.sendMessage(betaMessage);
|
||||
await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Opening first server once restores its history immediately', async () => {
|
||||
await openSavedRoomByName(scenario.client.page, alphaServerName);
|
||||
await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Opening second server once restores its history immediately', async () => {
|
||||
await openSavedRoomByName(scenario.client.page, betaServerName);
|
||||
await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('syncs messages in a newly created text channel', async ({ createClient }) => {
|
||||
const scenario = await createChatScenario(createClient);
|
||||
const channelName = uniqueName('updates');
|
||||
@@ -143,6 +218,43 @@ interface ChatScenario {
|
||||
bobMessages: ChatMessagesPage;
|
||||
}
|
||||
|
||||
interface SingleClientChatScenario {
|
||||
client: Client;
|
||||
messages: ChatMessagesPage;
|
||||
room: ChatRoomPage;
|
||||
search: ServerSearchPage;
|
||||
}
|
||||
|
||||
async function createSingleClientChatScenario(createClient: () => Promise<Client>): Promise<SingleClientChatScenario> {
|
||||
const suffix = uniqueName('solo');
|
||||
const client = await createClient();
|
||||
const credentials = {
|
||||
username: `solo_${suffix}`,
|
||||
displayName: 'Solo',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
|
||||
await installChatFeatureMocks(client.page);
|
||||
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(
|
||||
credentials.username,
|
||||
credentials.displayName,
|
||||
credentials.password
|
||||
);
|
||||
|
||||
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
|
||||
return {
|
||||
client,
|
||||
messages: new ChatMessagesPage(client.page),
|
||||
room: new ChatRoomPage(client.page),
|
||||
search: new ServerSearchPage(client.page)
|
||||
};
|
||||
}
|
||||
|
||||
async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> {
|
||||
const suffix = uniqueName('chat');
|
||||
const serverName = `Chat Server ${suffix}`;
|
||||
@@ -217,6 +329,52 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
||||
};
|
||||
}
|
||||
|
||||
async function createServerAndOpenRoom(
|
||||
searchPage: ServerSearchPage,
|
||||
page: Page,
|
||||
serverName: string,
|
||||
description: string
|
||||
): Promise<void> {
|
||||
await searchPage.createServer(serverName, { description });
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
await waitForCurrentRoomName(page, serverName);
|
||||
}
|
||||
|
||||
async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
|
||||
const roomButton = page.locator(`button[title="${roomName}"]`);
|
||||
|
||||
await expect(roomButton).toBeVisible({ timeout: 20_000 });
|
||||
await roomButton.click();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||
await waitForCurrentRoomName(page, roomName);
|
||||
}
|
||||
|
||||
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(expectedRoomName) => {
|
||||
interface RoomShape { name?: string }
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||
|
||||
return currentRoom?.name === expectedRoomName;
|
||||
},
|
||||
roomName,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
async function installChatFeatureMocks(page: Page): Promise<void> {
|
||||
await page.route('**/api/klipy/config', async (route) => {
|
||||
await route.fulfill({
|
||||
|
||||
264
e2e/tests/chat/notifications.spec.ts
Normal file
264
e2e/tests/chat/notifications.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page
|
||||
} from '@playwright/test';
|
||||
import {
|
||||
test,
|
||||
type Client
|
||||
} from '../../fixtures/multi-client';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
|
||||
interface DesktopNotificationRecord {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface NotificationScenario {
|
||||
alice: Client;
|
||||
bob: Client;
|
||||
aliceRoom: ChatRoomPage;
|
||||
bobRoom: ChatRoomPage;
|
||||
bobMessages: ChatMessagesPage;
|
||||
serverName: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
test.describe('Chat notifications', () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test('shows desktop notifications and unread badges for inactive channels', async ({ createClient }) => {
|
||||
const scenario = await createNotificationScenario(createClient);
|
||||
const message = `Background notification ${uniqueName('msg')}`;
|
||||
|
||||
await test.step('Bob sends a message to Alice\'s inactive channel', async () => {
|
||||
await clearDesktopNotifications(scenario.alice.page);
|
||||
await scenario.bobRoom.joinTextChannel(scenario.channelName);
|
||||
await scenario.bobMessages.sendMessage(message);
|
||||
});
|
||||
|
||||
await test.step('Alice receives a desktop notification with the channel preview', async () => {
|
||||
const notification = await waitForDesktopNotification(scenario.alice.page);
|
||||
|
||||
expect(notification).toEqual({
|
||||
title: `${scenario.serverName} · #${scenario.channelName}`,
|
||||
body: `Bob: ${message}`
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Alice sees unread badges for the room and the inactive channel', async () => {
|
||||
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 });
|
||||
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps unread badges visible when a muted channel suppresses desktop popups', async ({ createClient }) => {
|
||||
const scenario = await createNotificationScenario(createClient);
|
||||
const message = `Muted notification ${uniqueName('msg')}`;
|
||||
|
||||
await test.step('Alice mutes the inactive text channel', async () => {
|
||||
await muteTextChannel(scenario.alice.page, scenario.channelName);
|
||||
await clearDesktopNotifications(scenario.alice.page);
|
||||
});
|
||||
|
||||
await test.step('Bob sends a message into the muted channel', async () => {
|
||||
await scenario.bobRoom.joinTextChannel(scenario.channelName);
|
||||
await scenario.bobMessages.sendMessage(message);
|
||||
});
|
||||
|
||||
await test.step('Alice still sees unread badges for the room and channel', async () => {
|
||||
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 });
|
||||
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Alice does not get a muted desktop popup', async () => {
|
||||
const notificationAppeared = await waitForAnyDesktopNotification(scenario.alice.page, 1_500);
|
||||
|
||||
expect(notificationAppeared).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createNotificationScenario(createClient: () => Promise<Client>): Promise<NotificationScenario> {
|
||||
const suffix = uniqueName('notify');
|
||||
const serverName = `Notifications Server ${suffix}`;
|
||||
const channelName = uniqueName('updates');
|
||||
const aliceCredentials = {
|
||||
username: `alice_${suffix}`,
|
||||
displayName: 'Alice',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const bobCredentials = {
|
||||
username: `bob_${suffix}`,
|
||||
displayName: 'Bob',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
|
||||
await installDesktopNotificationSpy(alice.page);
|
||||
|
||||
await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password);
|
||||
await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password);
|
||||
|
||||
const aliceSearch = new ServerSearchPage(alice.page);
|
||||
|
||||
await aliceSearch.createServer(serverName, {
|
||||
description: 'E2E notification coverage server'
|
||||
});
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
const bobSearch = new ServerSearchPage(bob.page);
|
||||
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
|
||||
|
||||
await bobSearch.searchInput.fill(serverName);
|
||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||
await serverCard.click();
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
const bobRoom = new ChatRoomPage(bob.page);
|
||||
const aliceMessages = new ChatMessagesPage(alice.page);
|
||||
const bobMessages = new ChatMessagesPage(bob.page);
|
||||
|
||||
await aliceMessages.waitForReady();
|
||||
await bobMessages.waitForReady();
|
||||
await aliceRoom.ensureTextChannelExists(channelName);
|
||||
await expect(getTextChannelButton(alice.page, channelName)).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
return {
|
||||
alice,
|
||||
bob,
|
||||
aliceRoom,
|
||||
bobRoom,
|
||||
bobMessages,
|
||||
serverName,
|
||||
channelName
|
||||
};
|
||||
}
|
||||
|
||||
async function registerUser(page: Page, username: string, displayName: string, password: string): Promise<void> {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(username, displayName, password);
|
||||
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function installDesktopNotificationSpy(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const notifications: DesktopNotificationRecord[] = [];
|
||||
|
||||
class MockNotification {
|
||||
static permission = 'granted';
|
||||
|
||||
static async requestPermission(): Promise<NotificationPermission> {
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
onclick: (() => void) | null = null;
|
||||
|
||||
constructor(title: string, options?: NotificationOptions) {
|
||||
notifications.push({
|
||||
title,
|
||||
body: options?.body ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, '__desktopNotifications', {
|
||||
value: notifications,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'Notification', {
|
||||
value: MockNotification,
|
||||
configurable: true,
|
||||
writable: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function clearDesktopNotifications(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
(window as WindowWithDesktopNotifications).__desktopNotifications.length = 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDesktopNotification(page: Page): Promise<DesktopNotificationRecord> {
|
||||
await expect.poll(
|
||||
async () => (await readDesktopNotifications(page)).length,
|
||||
{
|
||||
timeout: 20_000,
|
||||
message: 'Expected a desktop notification to be emitted'
|
||||
}
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
const notifications = await readDesktopNotifications(page);
|
||||
|
||||
return notifications[notifications.length - 1];
|
||||
}
|
||||
|
||||
async function waitForAnyDesktopNotification(page: Page, timeout: number): Promise<boolean> {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
() => (window as WindowWithDesktopNotifications).__desktopNotifications.length > 0,
|
||||
undefined,
|
||||
{ timeout }
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readDesktopNotifications(page: Page): Promise<DesktopNotificationRecord[]> {
|
||||
return page.evaluate(() => {
|
||||
return [...(window as WindowWithDesktopNotifications).__desktopNotifications];
|
||||
});
|
||||
}
|
||||
|
||||
async function muteTextChannel(page: Page, channelName: string): Promise<void> {
|
||||
const channelButton = getTextChannelButton(page, channelName);
|
||||
const contextMenu = page.locator('app-context-menu');
|
||||
|
||||
await expect(channelButton).toBeVisible({ timeout: 20_000 });
|
||||
await channelButton.click({ button: 'right' });
|
||||
await expect(contextMenu.getByRole('button', { name: 'Mute Notifications' })).toBeVisible({ timeout: 10_000 });
|
||||
await contextMenu.getByRole('button', { name: 'Mute Notifications' }).click();
|
||||
await expect(contextMenu).toHaveCount(0);
|
||||
}
|
||||
|
||||
function getSavedRoomButton(page: Page, roomName: string): Locator {
|
||||
return page.locator(`button[title="${roomName}"]`).first();
|
||||
}
|
||||
|
||||
function getTextChannelButton(page: Page, channelName: string): Locator {
|
||||
return page.locator('app-rooms-side-panel').first()
|
||||
.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`)
|
||||
.first();
|
||||
}
|
||||
|
||||
function getUnreadBadge(container: Locator): Locator {
|
||||
return container.locator('span.rounded-full').first();
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
interface WindowWithDesktopNotifications extends Window {
|
||||
__desktopNotifications: DesktopNotificationRecord[];
|
||||
}
|
||||
Reference in New Issue
Block a user