fix: Fix multiple bugs with new authentication flow
This commit is contained in:
@@ -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);
|
||||
|
||||
205
e2e/helpers/multi-device-session.ts
Normal file
205
e2e/helpers/multi-device-session.ts
Normal 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 });
|
||||
}
|
||||
@@ -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('');
|
||||
|
||||
@@ -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
27
e2e/run-playwright.mjs
Normal 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);
|
||||
});
|
||||
153
e2e/tests/auth/login-return-url.spec.ts
Normal file
153
e2e/tests/auth/login-return-url.spec.ts
Normal 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)}`;
|
||||
}
|
||||
93
e2e/tests/auth/multi-device-session.spec.ts
Normal file
93
e2e/tests/auth/multi-device-session.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user