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

This commit is contained in:
2026-04-11 16:48:26 +02:00
parent f33440a827
commit 391d9235f1
25 changed files with 2968 additions and 67 deletions

390
e2e/pages/chat-room.page.ts Normal file
View 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, '\\$&');
}