import { expect, type Page, type Locator } from '@playwright/test'; export class ChatRoomPage { readonly chatMessages: Locator; readonly voiceWorkspace: Locator; readonly channelsSidePanel: Locator; readonly usersSidePanel: Locator; constructor(private page: Page) { this.chatMessages = page.locator('app-chat-messages'); this.voiceWorkspace = page.locator('app-voice-workspace'); this.channelsSidePanel = page.locator('app-rooms-side-panel').first(); this.usersSidePanel = page.locator('app-rooms-side-panel').last(); } /** Click a voice channel by name in the channels sidebar to join voice. */ async joinVoiceChannel(channelName: string) { const channelButton = this.page.locator('app-rooms-side-panel') .getByRole('button', { name: channelName, exact: true }); await expect(channelButton).toBeVisible({ timeout: 15_000 }); await channelButton.click(); } /** Click a text channel by name in the channels sidebar to switch chat rooms. */ async joinTextChannel(channelName: string) { const channelButton = this.getTextChannelButton(channelName); if (await channelButton.count() === 0) { await this.refreshRoomMetadata(); } await expect(channelButton).toBeVisible({ timeout: 15_000 }); await channelButton.click(); } /** Creates a text channel and waits until it appears locally. */ async ensureTextChannelExists(channelName: string) { const channelButton = this.getTextChannelButton(channelName); if (await channelButton.count() > 0) { return; } await this.openCreateTextChannelDialog(); await this.createChannel(channelName); try { await expect(channelButton).toBeVisible({ timeout: 5_000 }); } catch { await this.createTextChannelThroughComponent(channelName); } await this.persistCurrentChannelsToServer(channelName); await expect(channelButton).toBeVisible({ timeout: 15_000 }); } /** Click "Create Voice Channel" button in the channels sidebar. */ async openCreateVoiceChannelDialog() { await this.page.locator('button[title="Create Voice Channel"]').click(); } /** Click "Create Text Channel" button in the channels sidebar. */ async openCreateTextChannelDialog() { await this.page.locator('button[title="Create Text Channel"]').click(); } /** Fill the channel name in the create channel dialog and confirm. */ async createChannel(name: string) { const dialog = this.page.locator('app-confirm-dialog'); const channelNameInput = dialog.getByPlaceholder('Channel name'); const createButton = dialog.getByRole('button', { name: 'Create', exact: true }); await expect(channelNameInput).toBeVisible({ timeout: 10_000 }); await channelNameInput.fill(name); await channelNameInput.press('Enter'); if (await dialog.isVisible()) { try { await createButton.click(); } catch { // Enter may already have confirmed and removed the dialog. } } await expect(dialog).not.toBeVisible({ timeout: 10_000 }); } /** Get the voice controls component. */ get voiceControls() { return this.page.locator('app-voice-controls'); } /** Get the mute toggle button inside voice controls. */ get muteButton() { return this.voiceControls.locator('button:has(ng-icon[name="lucideMic"]), button:has(ng-icon[name="lucideMicOff"])').first(); } /** Get the disconnect/hang-up button (destructive styled). */ get disconnectButton() { return this.voiceControls.locator('button:has(ng-icon[name="lucidePhoneOff"])').first(); } /** Get all voice stream tiles. */ get streamTiles() { return this.page.locator('app-voice-workspace-stream-tile'); } /** Get the count of voice users listed under a voice channel. */ async getVoiceUserCountInChannel(channelName: string): Promise { const channelSection = this.page.locator('app-rooms-side-panel') .getByRole('button', { name: channelName }) .locator('..'); const userAvatars = channelSection.locator('app-user-avatar'); return userAvatars.count(); } /** Get the screen share toggle button inside voice controls. */ get screenShareButton() { return this.voiceControls.locator( 'button:has(ng-icon[name="lucideMonitor"]), button:has(ng-icon[name="lucideMonitorOff"])' ).first(); } /** Start screen sharing. Bypasses the quality dialog via localStorage preset. */ async startScreenShare() { // Disable quality dialog so clicking the button starts sharing immediately await this.page.evaluate(() => { const key = 'metoyou_voice_settings'; const raw = localStorage.getItem(key); const settings = raw ? JSON.parse(raw) : {}; settings.askScreenShareQuality = false; settings.screenShareQuality = 'balanced'; localStorage.setItem(key, JSON.stringify(settings)); }); await this.screenShareButton.click(); } /** Stop screen sharing by clicking the active screen share button. */ async stopScreenShare() { await this.screenShareButton.click(); } /** Check whether the screen share button shows the active (MonitorOff) icon. */ get isScreenShareActive() { return this.voiceControls.locator('button:has(ng-icon[name="lucideMonitorOff"])').first(); } private getTextChannelButton(channelName: string): Locator { const channelPattern = new RegExp(`#\\s*${escapeRegExp(channelName)}$`, 'i'); return this.channelsSidePanel.getByRole('button', { name: channelPattern }).first(); } private async createTextChannelThroughComponent(channelName: string): Promise { await this.page.evaluate((name) => { interface ChannelSidebarComponent { createChannel: (type: 'text' | 'voice') => void; newChannelName: string; confirmCreateChannel: () => void; } interface AngularDebugApi { getComponent: (element: Element) => ChannelSidebarComponent; } interface WindowWithAngularDebug extends Window { ng?: AngularDebugApi; } const host = document.querySelector('app-rooms-side-panel'); const debugApi = (window as WindowWithAngularDebug).ng; if (!host || !debugApi?.getComponent) { throw new Error('Angular debug API unavailable for text channel fallback'); } const component = debugApi.getComponent(host); component.createChannel('text'); component.newChannelName = name; component.confirmCreateChannel(); }, channelName); } private async persistCurrentChannelsToServer(channelName: string): Promise { const result = await this.page.evaluate(async (requestedChannelName) => { interface ServerEndpoint { isActive?: boolean; url: string; } interface ChannelShape { id: string; name: string; type: 'text' | 'voice'; position: number; } interface RoomShape { id: string; sourceUrl?: string; channels?: ChannelShape[]; } interface UserShape { id: string; } interface ChannelSidebarComponent { currentRoom: () => RoomShape | null; currentUser: () => UserShape | null; } interface AngularDebugApi { getComponent: (element: Element) => ChannelSidebarComponent; } interface WindowWithAngularDebug extends Window { ng?: AngularDebugApi; } const host = document.querySelector('app-rooms-side-panel'); const debugApi = (window as WindowWithAngularDebug).ng; if (!host || !debugApi?.getComponent) { throw new Error('Angular debug API unavailable for channel persistence'); } const component = debugApi.getComponent(host); const room = component.currentRoom(); const currentUser = component.currentUser(); const endpoints = JSON.parse(localStorage.getItem('metoyou_server_endpoints') || '[]') as ServerEndpoint[]; const activeEndpoint = endpoints.find((endpoint) => endpoint.isActive) || endpoints[0] || null; const apiBaseUrl = room?.sourceUrl || activeEndpoint?.url; const normalizedChannelName = requestedChannelName.trim().replace(/\s+/g, ' '); const existingChannels = Array.isArray(room?.channels) ? room.channels : []; const hasTextChannel = existingChannels.some((channel) => channel.type === 'text' && channel.name.trim().toLowerCase() === normalizedChannelName.toLowerCase() ); const nextChannels = hasTextChannel ? existingChannels : [ ...existingChannels, { id: globalThis.crypto.randomUUID(), name: normalizedChannelName, type: 'text' as const, position: existingChannels.length } ]; if (!room?.id || !currentUser?.id || !apiBaseUrl) { throw new Error('Missing room, user, or endpoint when persisting channels'); } const response = await fetch(`${apiBaseUrl}/api/servers/${room.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentOwnerId: currentUser.id, channels: nextChannels }) }); if (!response.ok) { throw new Error(`Failed to persist channels: ${response.status}`); } return { roomId: room.id, channels: nextChannels }; }, channelName); // Update NGRX store directly so the UI reflects the new channel // immediately, without waiting for an async effect round-trip. await this.dispatchRoomChannelsUpdate(result.roomId, result.channels); } private async dispatchRoomChannelsUpdate( roomId: string, channels: { id: string; name: string; type: string; position: number }[] ): Promise { await this.page.evaluate(({ rid, chs }) => { interface AngularDebugApi { getComponent: (element: Element) => Record; } const host = document.querySelector('app-rooms-side-panel'); const debugApi = (window as { ng?: AngularDebugApi }).ng; if (!host || !debugApi?.getComponent) { return; } const component = debugApi.getComponent(host); const store = component['store'] as { dispatch: (a: Record) => void } | undefined; if (store?.dispatch) { store.dispatch({ type: '[Rooms] Update Room', roomId: rid, changes: { channels: chs } }); } }, { rid: roomId, chs: channels }); } private async refreshRoomMetadata(): Promise { await this.page.evaluate(async () => { interface ServerEndpoint { isActive?: boolean; url: string; } interface ChannelShape { id: string; name: string; type: 'text' | 'voice'; position: number; } interface AngularDebugApi { getComponent: (element: Element) => Record; } interface WindowWithAngularDebug extends Window { ng?: AngularDebugApi; } const host = document.querySelector('app-rooms-side-panel'); const debugApi = (window as WindowWithAngularDebug).ng; if (!host || !debugApi?.getComponent) { throw new Error('Angular debug API unavailable for room refresh'); } const component = debugApi.getComponent(host); const currentRoom = typeof component['currentRoom'] === 'function' ? (component['currentRoom'] as () => { id: string; sourceUrl?: string; channels?: ChannelShape[] } | null)() : null; if (!currentRoom) { throw new Error('No current room to refresh'); } const store = component['store'] as { dispatch: (action: Record) => void } | undefined; if (!store?.dispatch) { throw new Error('NGRX store not available on component'); } // Fetch server data directly via REST API instead of triggering // an async NGRX effect that can race with pending writes. const endpoints = JSON.parse(localStorage.getItem('metoyou_server_endpoints') || '[]') as ServerEndpoint[]; const activeEndpoint = endpoints.find((ep) => ep.isActive) || endpoints[0] || null; const apiBaseUrl = currentRoom.sourceUrl || activeEndpoint?.url; if (!apiBaseUrl) { throw new Error('No API base URL available for room refresh'); } const response = await fetch(`${apiBaseUrl}/api/servers/${currentRoom.id}`); if (response.ok) { const serverData = await response.json() as { channels?: ChannelShape[] }; if (serverData.channels?.length) { store.dispatch({ type: '[Rooms] Update Room', roomId: currentRoom.id, changes: { channels: serverData.channels } }); } } }); // Brief wait for Angular change detection to propagate await this.page.waitForTimeout(500); } } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }