Some checks failed
Deploy Web Apps / deploy (push) Has been cancelled
Queue Release Build / prepare (push) Successful in 21s
Queue Release Build / build-linux (push) Successful in 27m44s
Queue Release Build / build-windows (push) Successful in 32m16s
Queue Release Build / finalize (push) Successful in 1m54s
391 lines
13 KiB
TypeScript
391 lines
13 KiB
TypeScript
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<number> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
await this.page.evaluate(({ rid, chs }) => {
|
|
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;
|
|
}
|
|
|
|
const component = debugApi.getComponent(host);
|
|
const store = component['store'] as { dispatch: (a: Record<string, unknown>) => void } | undefined;
|
|
|
|
if (store?.dispatch) {
|
|
store.dispatch({
|
|
type: '[Rooms] Update Room',
|
|
roomId: rid,
|
|
changes: { channels: chs }
|
|
});
|
|
}
|
|
}, { rid: roomId, chs: channels });
|
|
}
|
|
|
|
private async refreshRoomMetadata(): Promise<void> {
|
|
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<string, unknown>;
|
|
}
|
|
|
|
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<string, unknown>) => 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, '\\$&');
|
|
}
|