fix: Fix multiple bugs with new authentication flow

This commit is contained in:
2026-06-07 15:04:21 +02:00
parent 9fc26b1ccf
commit 83456c018c
137 changed files with 4710 additions and 281 deletions

View File

@@ -48,7 +48,8 @@ export const test = base.extend<MultiClientFixture>({
const context = await browser.newContext({
permissions: ['microphone', 'camera'],
baseURL: 'http://localhost:4200'
baseURL: 'http://localhost:4200',
viewport: { width: 1440, height: 900 }
});
await installTestServerEndpoint(context, testServer.port);

View File

@@ -0,0 +1,205 @@
import { expect, type Page } from '@playwright/test';
import { type Client } from '../fixtures/multi-client';
import { LoginPage } from '../pages/login.page';
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';
export const MULTI_DEVICE_PASSWORD = 'TestPass123!';
export const MULTI_DEVICE_VOICE_CHANNEL = 'General';
export interface MultiDeviceCredentials {
username: string;
displayName: string;
password: string;
}
export interface MultiDeviceScenario {
clientA: Client;
clientB: Client;
credentials: MultiDeviceCredentials;
serverName: string;
messagesA: ChatMessagesPage;
messagesB: ChatMessagesPage;
roomA: ChatRoomPage;
roomB: ChatRoomPage;
}
export function uniqueMultiDeviceName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10_000)}`;
}
export async function createMultiDeviceScenario(
createClient: () => Promise<Client>,
options: { suffix?: string; serverDescription?: string } = {}
): Promise<MultiDeviceScenario> {
const suffix = options.suffix ?? uniqueMultiDeviceName('multi-device');
const credentials: MultiDeviceCredentials = {
username: `multi_${suffix}`,
displayName: 'Multi Device User',
password: MULTI_DEVICE_PASSWORD
};
const serverName = `Multi Device Server ${suffix}`;
const clientA = await createClient();
const clientB = await createClient();
await warmClientPage(clientA.page);
await warmClientPage(clientB.page);
const registerPage = new RegisterPage(clientA.page);
await registerPage.goto();
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
await expect(clientA.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
const searchA = new ServerSearchPage(clientA.page);
await searchA.createServer(serverName, {
description: options.serverDescription ?? 'Multi-device session coverage'
});
await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await waitForCurrentRoomName(clientA.page, serverName);
const roomA = new ChatRoomPage(clientA.page);
await roomA.ensureVoiceChannelExists(MULTI_DEVICE_VOICE_CHANNEL);
await loginSecondDeviceIntoServer(clientB.page, credentials, serverName);
await waitForCurrentRoomName(clientB.page, serverName);
const messagesA = new ChatMessagesPage(clientA.page);
const messagesB = new ChatMessagesPage(clientB.page);
const roomB = new ChatRoomPage(clientB.page);
await messagesA.waitForReady();
await messagesB.waitForReady();
return {
clientA,
clientB,
credentials,
serverName,
messagesA,
messagesB,
roomA,
roomB
};
}
export async function loginSecondDeviceIntoServer(
page: Page,
credentials: MultiDeviceCredentials,
serverName: string
): Promise<void> {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(credentials.username, credentials.password);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
const search = new ServerSearchPage(page);
await search.joinServerFromSearch(serverName);
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
}
export async function expectCrossDeviceMessage(
sender: ChatMessagesPage,
receiver: ChatMessagesPage,
message: string,
timeout = 60_000
): Promise<void> {
await sender.sendMessage(message);
await expect.poll(async () => {
return await receiver.getMessageItemByText(message).isVisible().catch(() => false);
}, { timeout }).toBe(true);
}
async function warmClientPage(page: Page): Promise<void> {
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle').catch(() => undefined);
}
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 }
);
}
export async function readClientInstanceId(page: Page): Promise<string | null> {
return page.evaluate(() => localStorage.getItem('metoyou.clientInstanceId'));
}
export async function logoutFromMenu(page: Page): Promise<void> {
const menuButton = page.getByRole('button', { name: 'Menu' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
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 });
}
export function channelsSidePanel(page: Page) {
return page.locator('app-rooms-side-panel').first();
}
export function membersSidePanel(page: Page) {
return page.locator('app-rooms-side-panel').last();
}
export function passiveVoiceChannelJoinBadge(page: Page, channelName = MULTI_DEVICE_VOICE_CHANNEL) {
return page
.locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`)
.getByText('Join', { exact: true });
}
export async function expectPassiveVoiceOnDevice(
page: Page,
options: { timeout?: number; displayName?: string; channelName?: string } = {}
): Promise<void> {
const timeout = options.timeout ?? 45_000;
const channelName = options.channelName ?? MULTI_DEVICE_VOICE_CHANNEL;
const displayName = options.displayName;
await expect.poll(async () => {
const membersLabel = await membersSidePanel(page)
.getByText('In voice on another device', { exact: false })
.isVisible()
.catch(() => false);
const joinBadge = await passiveVoiceChannelJoinBadge(page, channelName).isVisible().catch(() => false);
const grayedVoiceUser = displayName
? await channelsSidePanel(page).locator('.opacity-50').filter({ hasText: displayName }).first().isVisible().catch(() => false)
: false;
return membersLabel || joinBadge || grayedVoiceUser;
}, { timeout }).toBe(true);
}
export async function expectActiveVoiceOnDevice(page: Page, timeout = 20_000): Promise<void> {
await expect(page.locator('app-voice-controls, app-voice-workspace').first()).toBeVisible({ timeout });
}

View File

@@ -34,9 +34,22 @@ export class ChatMessagesPage {
}
async sendMessage(content: string): Promise<void> {
await this.waitForReady();
await this.composerInput.fill(content);
await this.sendButton.click();
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await this.waitForReady();
await this.composerInput.fill(content);
await expect(this.composerInput).toHaveValue(content, { timeout: 5_000 });
await expect(this.sendButton).toBeEnabled({ timeout: 5_000 });
await this.sendButton.click();
return;
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error ? lastError : new Error('Failed to send chat message');
}
async typeDraft(content: string): Promise<void> {
@@ -44,6 +57,13 @@ export class ChatMessagesPage {
await this.composerInput.fill(content);
}
/** Types into the composer in a way that emits input/typing events (not just fill). */
async typeDraftWithTypingEvents(content: string): Promise<void> {
await this.waitForReady();
await this.composerInput.click();
await this.composerInput.pressSequentially(content, { delay: 40 });
}
async clearDraft(): Promise<void> {
await this.waitForReady();
await this.composerInput.fill('');

View File

@@ -10,15 +10,14 @@ 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.form = page.locator('form').filter({ has: page.locator('#login-username') });
this.usernameInput = page.locator('#login-username');
this.passwordInput = page.locator('#login-password');
this.serverSelect = page.locator('#login-server');
this.submitButton = this.form.getByRole('button', { name: 'Login' });
this.errorText = page.locator('.text-destructive');
this.registerLink = this.form.getByRole('button', { name: 'Register' });
this.registerLink = page.getByRole('button', { name: 'Register' });
}
async goto() {

27
e2e/run-playwright.mjs Normal file
View File

@@ -0,0 +1,27 @@
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const e2eDirectory = fileURLToPath(new URL('.', import.meta.url));
const env = { ...process.env };
const browsersPath = env.PLAYWRIGHT_BROWSERS_PATH;
if (browsersPath?.includes('/cursor-sandbox-cache/')) {
delete env.PLAYWRIGHT_BROWSERS_PATH;
}
const [command = 'test', ...args] = process.argv.slice(2);
const executable = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const child = spawn(executable, ['playwright', command, ...args], {
cwd: e2eDirectory,
env,
stdio: 'inherit'
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});

View File

@@ -0,0 +1,153 @@
import { test, expect } from '../../fixtures/multi-client';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
interface TestUser {
username: string;
displayName: string;
password: string;
}
test.describe('Login returnUrl handling', () => {
test.describe.configure({ timeout: 120_000 });
test('unwraps nested login returnUrl chains after successful login', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = uniqueName('nested-return');
const user: TestUser = {
username: `user_${suffix}`,
displayName: 'Return Url User',
password: 'TestPass123!'
};
await test.step('Create an account', async () => {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
});
await test.step('Log out and open a deeply nested login returnUrl', async () => {
await logout(page);
const nestedReturnUrl = '/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers';
await page.goto(`/login?returnUrl=${encodeURIComponent(nestedReturnUrl)}`, {
waitUntil: 'domcontentloaded'
});
await expect(page.locator('#login-username')).toBeVisible({ timeout: 15_000 });
});
await test.step('Login lands on the original destination instead of looping on /login', async () => {
const loginPage = new LoginPage(page);
await loginPage.login(user.username, user.password);
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
await expect(page).not.toHaveURL(/returnUrl=.*login/);
});
});
test('redirects unauthenticated /servers visits to login and returns there after login', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = uniqueName('servers-return');
const user: TestUser = {
username: `user_${suffix}`,
displayName: 'Servers Return User',
password: 'TestPass123!'
};
await test.step('Create an account and log out', async () => {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
await logout(page);
});
await test.step('Visiting /servers sends the user to a single-level login returnUrl', async () => {
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
await expect(page).not.toHaveURL(/returnUrl=.*login/);
});
await test.step('Logging in returns to /servers', async () => {
const loginPage = new LoginPage(page);
await loginPage.login(user.username, user.password);
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
});
});
test('lets a returning user log back in after an expired session redirect', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = uniqueName('expired-session');
const user: TestUser = {
username: `user_${suffix}`,
displayName: 'Expired Session User',
password: 'TestPass123!'
};
await test.step('Create an account', async () => {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
});
await test.step('Simulate an expired session while keeping the persisted user id', async () => {
await page.evaluate(() => {
const storageKey = 'metoyou.authTokens';
const raw = localStorage.getItem(storageKey);
if (!raw) {
return;
}
const parsed = JSON.parse(raw) as Record<string, { token: string; expiresAt: number }>;
const expiredStore = Object.fromEntries(
Object.entries(parsed).map(([url, entry]) => [url, { ...entry, expiresAt: 0 }])
);
localStorage.setItem(storageKey, JSON.stringify(expiredStore));
});
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
await expect(page).not.toHaveURL(/returnUrl=.*login/);
});
await test.step('The user can authenticate again and reach /servers', async () => {
const loginPage = new LoginPage(page);
await loginPage.login(user.username, user.password);
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
});
});
});
async function logout(page: import('@playwright/test').Page): Promise<void> {
const menuButton = page.getByRole('button', { name: 'Menu' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
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 });
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -0,0 +1,93 @@
import {
test,
expect
} from '../../fixtures/multi-client';
import {
MULTI_DEVICE_VOICE_CHANNEL,
channelsSidePanel,
createMultiDeviceScenario,
expectCrossDeviceMessage,
expectActiveVoiceOnDevice,
expectPassiveVoiceOnDevice,
logoutFromMenu,
membersSidePanel,
passiveVoiceChannelJoinBadge,
readClientInstanceId,
uniqueMultiDeviceName
} from '../../helpers/multi-device-session';
test.describe('Multi-device session', () => {
test.describe.configure({ timeout: 300_000, retries: 1 });
test('covers identity, chat sync, typing exclusion, and voice exclusivity', async ({ createClient }) => {
const scenario = await createMultiDeviceScenario(createClient);
const messageAtoB = `Cross-device A to B ${uniqueMultiDeviceName('msg')}`;
const messageBtoA = `Cross-device B to A ${uniqueMultiDeviceName('msg')}`;
const typingDraft = `Typing draft ${uniqueMultiDeviceName('draft')}`;
await test.step('assigns distinct clientInstanceId per browser context', async () => {
const instanceA = await readClientInstanceId(scenario.clientA.page);
const instanceB = await readClientInstanceId(scenario.clientB.page);
expect(instanceA).toBeTruthy();
expect(instanceB).toBeTruthy();
expect(instanceA).not.toEqual(instanceB);
});
await test.step('syncs chat from device A to device B', async () => {
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
});
await test.step('syncs chat from device B to device A', async () => {
await expectCrossDeviceMessage(scenario.messagesB, scenario.messagesA, messageBtoA);
});
await test.step('does not show own typing indicator on the other device for the same user', async () => {
await scenario.messagesA.typeDraftWithTypingEvents(typingDraft);
await expect(
scenario.clientB.page.getByText(`${scenario.credentials.displayName} is typing`, { exact: false })
).toHaveCount(0, { timeout: 5_000 });
});
await test.step('shows passive in-voice UI on the second device when the first joins voice', async () => {
await scenario.roomA.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
await expectActiveVoiceOnDevice(scenario.clientA.page);
await expectPassiveVoiceOnDevice(scenario.clientB.page, {
displayName: scenario.credentials.displayName
});
await expect(
membersSidePanel(scenario.clientB.page).getByText('In voice on another device', { exact: false })
).toBeVisible({ timeout: 20_000 });
await expect(
channelsSidePanel(scenario.clientB.page).locator('.opacity-50').filter({
hasText: scenario.credentials.displayName
}).first()
).toBeVisible({ timeout: 20_000 });
});
await test.step('shows Join takeover affordance on passive device voice channel', async () => {
await expect(passiveVoiceChannelJoinBadge(scenario.clientB.page)).toBeVisible({ timeout: 20_000 });
});
await test.step('transfers voice ownership when the passive device takes over', async () => {
await scenario.roomB.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
await expectActiveVoiceOnDevice(scenario.clientB.page);
await expectPassiveVoiceOnDevice(scenario.clientA.page, {
displayName: scenario.credentials.displayName
});
});
await test.step('keeps the second device logged in when the first device logs out', async () => {
const message = `Still logged in ${uniqueMultiDeviceName('logout')}`;
await logoutFromMenu(scenario.clientA.page);
await scenario.messagesB.sendMessage(message);
await expect(scenario.messagesB.getMessageItemByText(message)).toBeVisible({ timeout: 20_000 });
await expect(scenario.clientB.page).toHaveURL(/\/room\//, { timeout: 10_000 });
});
});
});

View File

@@ -48,14 +48,13 @@ test.describe('User session data isolation', () => {
await test.step('Alice registers and creates local chat history', async () => {
await registerUser(client.page, alice);
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
await createServerAndSendMessage(client.page, alice, 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);
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
});
} finally {
await closePersistentClient(client);
@@ -88,11 +87,11 @@ test.describe('User session data isolation', () => {
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 createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
await restartPersistentClient(client, testServer.port);
await openApp(client.page);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
});
await test.step('Bob starts from a blank slate in the same browser profile', async () => {
@@ -102,11 +101,11 @@ test.describe('User session data isolation', () => {
});
await test.step('Bob gets only his own saved room and history after a restart', async () => {
await createServerAndSendMessage(client.page, bobServerName, bobMessage);
await createServerAndSendMessage(client.page, bob, bobServerName, bobMessage);
await restartPersistentClient(client, testServer.port);
await openApp(client.page);
await expectSavedRoomAndHistory(client.page, bobServerName, bobMessage);
await expectSavedRoomAndHistory(client.page, bob, bobServerName, bobMessage);
await expectSavedRoomHidden(client.page, aliceServerName);
});
@@ -117,7 +116,7 @@ test.describe('User session data isolation', () => {
await expectSavedRoomVisible(client.page, aliceServerName);
await expectSavedRoomHidden(client.page, bobServerName);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
});
} finally {
await closePersistentClient(client);
@@ -194,32 +193,58 @@ async function logoutUser(page: Page): Promise<void> {
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
}
async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise<void> {
async function createServerAndSendMessage(page: Page, user: TestUser, 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 loginIfNeeded(page, user);
await ensureCurrentUserScope(page, user);
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
if (await waitForLoginForm(page, 5_000)) {
await loginUser(page, user);
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
}
await expect(searchPage.serverNameInput).toBeVisible({ timeout: 10_000 });
await searchPage.serverNameInput.fill(serverName);
await searchPage.serverDescriptionInput.fill(`User session isolation coverage for ${serverName}`);
await searchPage.createSubmitButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
await messagesPage.sendMessage(messageText);
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
await expectMessagePersistedInIndexedDb(page, messageText);
}
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> {
const railRoomButton = getRailSavedRoomButton(page, roomName);
const messagesPage = new ChatMessagesPage(page);
async function expectSavedRoomAndHistory(page: Page, user: TestUser, roomName: string, messageText: string): Promise<void> {
if (await waitForVisibleText(page, messageText, 5_000)) {
return;
}
await expect(railRoomButton).toBeVisible({ timeout: 20_000 });
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
const searchRoomButton = getSearchSavedRoomButton(page, roomName);
if (await new LoginPage(page).usernameInput.isVisible().catch(() => false)) {
await loginUser(page, user);
}
await expect(searchRoomButton).toBeVisible({ timeout: 20_000 });
await searchRoomButton.click();
await expectMessagePersistedInIndexedDb(page, messageText);
const persistedRoomId = await getPersistedRoomIdForMessage(page, messageText);
if (persistedRoomId) {
await openPersistedRoomById(page, user, persistedRoomId);
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
return;
}
if (await openSavedRoomFromRail(page, roomName)) {
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
return;
}
await joinServerFromSearchAfterLogin(page, user, roomName);
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
}
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
@@ -232,14 +257,17 @@ async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<
}
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
await expect(getRailSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
if (await page.getByText(roomName, { exact: false }).first()
.isVisible()
.catch(() => false)) {
return;
}
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
}
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
await expect(getRailSavedRoomButton(page, roomName)).toHaveCount(0);
if (!page.url().includes('/servers')) {
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
}
@@ -247,14 +275,227 @@ async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void
await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0);
}
function getRailSavedRoomButton(page: Page, roomName: string) {
return page.locator(`button[title="${roomName}"]`).first();
}
function getSearchSavedRoomButton(page: Page, roomName: string) {
return page.locator('app-server-browser').getByRole('button', { name: roomName, exact: true });
}
async function openSavedRoomFromRail(page: Page, roomName: string): Promise<boolean> {
try {
await expect(page.locator('app-servers-rail')).toBeVisible({ timeout: 10_000 });
const clicked = await page.locator('app-servers-rail button').evaluateAll((buttons, expectedName) => {
const expectedPrefix = expectedName.slice(0, 24);
const button = buttons.find((candidate) => {
const title = (candidate as HTMLButtonElement).title;
return title === expectedName || title.startsWith(expectedPrefix);
}) as HTMLButtonElement | undefined;
button?.click();
return !!button;
}, roomName);
if (!clicked) {
return await openSavedRoomFromDashboard(page, roomName);
}
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
return true;
} catch {
return await openSavedRoomFromDashboard(page, roomName);
}
}
async function openSavedRoomFromDashboard(page: Page, roomName: string): Promise<boolean> {
const roomNamePattern = new RegExp(escapeRegExp(roomName.slice(0, 24)));
const roomButton = page.getByRole('button', { name: roomNamePattern }).first();
try {
await expect(roomButton).toBeVisible({ timeout: 10_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
return true;
} catch {
return await joinVisibleServerFromDashboard(page, roomNamePattern);
}
}
async function joinVisibleServerFromDashboard(page: Page, roomNamePattern: RegExp): Promise<boolean> {
const serverRow = page.locator('div', { hasText: roomNamePattern }).filter({
has: page.getByRole('button', { name: 'Join' })
})
.last();
const joinButton = serverRow.getByRole('button', { name: 'Join' });
try {
await expect(joinButton).toBeVisible({ timeout: 10_000 });
await joinButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
return true;
} catch {
return false;
}
}
async function joinServerFromSearchAfterLogin(page: Page, user: TestUser, roomName: string): Promise<void> {
const searchPage = new ServerSearchPage(page);
await loginIfNeeded(page, user);
await searchPage.goto();
if (!await waitForServerSearch(page, 5_000)) {
await loginUser(page, user);
await searchPage.goto();
}
await expect(searchPage.searchInput).toBeVisible({ timeout: 15_000 });
await searchPage.searchInput.fill(roomName);
const serverCard = page.locator('div[title]', { hasText: roomName }).first();
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.dblclick();
}
async function loginIfNeeded(page: Page, user: TestUser): Promise<void> {
const loginPage = new LoginPage(page);
if (page.url().includes('/login')) {
await expect(loginPage.usernameInput).toBeVisible({ timeout: 15_000 });
await loginUser(page, user);
return;
}
if (await loginPage.usernameInput.isVisible().catch(() => false)) {
await loginUser(page, user);
}
}
async function ensureCurrentUserScope(page: Page, user: TestUser): Promise<void> {
if (await hasCurrentUserScope(page)) {
return;
}
await loginUser(page, user);
await expect.poll(() => hasCurrentUserScope(page), { timeout: 10_000 }).toBe(true);
}
async function hasCurrentUserScope(page: Page): Promise<boolean> {
return page.evaluate(() => !!localStorage.getItem('metoyou_currentUserId')?.trim());
}
async function openPersistedRoomById(page: Page, user: TestUser, roomId: string): Promise<void> {
for (let attempt = 1; attempt <= 3; attempt += 1) {
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
if (await waitForLoginForm(page, 5_000)) {
await loginUser(page, user);
continue;
}
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
if (!await waitForLoginForm(page, 2_000)) {
return;
}
await loginUser(page, user);
}
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
}
async function waitForLoginForm(page: Page, timeout: number): Promise<boolean> {
try {
await expect(new LoginPage(page).usernameInput).toBeVisible({ timeout });
return true;
} catch {
return false;
}
}
async function waitForServerSearch(page: Page, timeout: number): Promise<boolean> {
try {
await expect(new ServerSearchPage(page).searchInput).toBeVisible({ timeout });
return true;
} catch {
return false;
}
}
async function waitForVisibleText(page: Page, text: string, timeout: number): Promise<boolean> {
try {
await expect(page.getByText(text, { exact: false })).toBeVisible({ timeout });
return true;
} catch {
return false;
}
}
async function expectMessagePersistedInIndexedDb(page: Page, messageText: string): Promise<void> {
await expect.poll(
() => getPersistedRoomIdForMessage(page, messageText).then((roomId) => !!roomId),
{ timeout: 10_000 }
).toBe(true);
}
async function getPersistedRoomIdForMessage(page: Page, messageText: string): Promise<string | null> {
return page.evaluate(async (expectedContent) => {
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim();
const preferredDatabaseName = `metoyou::${encodeURIComponent(currentUserId || 'anonymous')}`;
const discoveredDatabaseNames = typeof indexedDB.databases === 'function'
? (await indexedDB.databases())
.map((database) => database.name)
.filter((name): name is string => !!name && (name === 'metoyou' || name.startsWith('metoyou::')))
: null;
const databaseNames = discoveredDatabaseNames ?? [preferredDatabaseName];
const remainingDatabaseNames = databaseNames.filter((name) => name !== preferredDatabaseName);
const orderedDatabaseNames = databaseNames.includes(preferredDatabaseName)
? [preferredDatabaseName].concat(remainingDatabaseNames)
: remainingDatabaseNames;
for (const databaseName of orderedDatabaseNames) {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(databaseName);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
try {
if (!database.objectStoreNames.contains('messages')) {
continue;
}
const transaction = database.transaction('messages', 'readonly');
const request = transaction.objectStore('messages').getAll();
const roomId = await new Promise<string | null>((resolve, reject) => {
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const match = ((request.result as { content?: string; roomId?: string }[]) ?? [])
.find((message) => message.content === expectedContent);
resolve(match?.roomId ?? null);
};
});
if (roomId) {
return roomId;
}
} finally {
database.close();
}
}
return null;
}, messageText);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
let lastError: unknown;

View File

@@ -150,6 +150,8 @@ test.describe('Mixed signal-config voice', () => {
}
});
let secondaryRoomId = '';
// ── Create rooms ────────────────────────────────────────────
await test.step('Create voice room on primary and chat room on secondary', async () => {
// Use a "both" user (client 0) to create both rooms
@@ -198,7 +200,6 @@ test.describe('Mixed signal-config voice', () => {
// Group D (secondary-only) needs invite to primary room.
let primaryRoomInviteUrl: string;
let secondaryRoomInviteUrl: string;
let secondaryRoomId = '';
await test.step('Create invite links for cross-signal rooms', async () => {
// Navigate to voice room to get its ID