test: Add playwright main usage test
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
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
This commit is contained in:
390
e2e/pages/chat-room.page.ts
Normal file
390
e2e/pages/chat-room.page.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
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, '\\$&');
|
||||
}
|
||||
Reference in New Issue
Block a user