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:
143
e2e/pages/chat-messages.page.ts
Normal file
143
e2e/pages/chat-messages.page.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page
|
||||
} from '@playwright/test';
|
||||
|
||||
export type ChatDropFilePayload = {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
base64: string;
|
||||
};
|
||||
|
||||
export class ChatMessagesPage {
|
||||
readonly composer: Locator;
|
||||
readonly composerInput: Locator;
|
||||
readonly sendButton: Locator;
|
||||
readonly typingIndicator: Locator;
|
||||
readonly gifButton: Locator;
|
||||
readonly gifPicker: Locator;
|
||||
readonly messageItems: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.composer = page.locator('app-chat-message-composer');
|
||||
this.composerInput = page.getByPlaceholder('Type a message...');
|
||||
this.sendButton = page.getByRole('button', { name: 'Send message' });
|
||||
this.typingIndicator = page.locator('app-typing-indicator');
|
||||
this.gifButton = page.getByRole('button', { name: 'Search KLIPY GIFs' });
|
||||
this.gifPicker = page.getByRole('dialog', { name: 'KLIPY GIF picker' });
|
||||
this.messageItems = page.locator('[data-message-id]');
|
||||
}
|
||||
|
||||
async waitForReady(): Promise<void> {
|
||||
await expect(this.composerInput).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async sendMessage(content: string): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill(content);
|
||||
await this.sendButton.click();
|
||||
}
|
||||
|
||||
async typeDraft(content: string): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill(content);
|
||||
}
|
||||
|
||||
async clearDraft(): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill('');
|
||||
}
|
||||
|
||||
async attachFiles(files: ChatDropFilePayload[]): Promise<void> {
|
||||
await this.waitForReady();
|
||||
|
||||
await this.composerInput.evaluate((element, payloads: ChatDropFilePayload[]) => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
for (const payload of payloads) {
|
||||
const binary = atob(payload.base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
dataTransfer.items.add(new File([bytes], payload.name, { type: payload.mimeType }));
|
||||
}
|
||||
|
||||
element.dispatchEvent(new DragEvent('drop', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer
|
||||
}));
|
||||
}, files);
|
||||
}
|
||||
|
||||
async openGifPicker(): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.gifButton.click();
|
||||
await expect(this.gifPicker).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async selectFirstGif(): Promise<void> {
|
||||
const gifCard = this.gifPicker.getByRole('button', { name: /click to select/i }).first();
|
||||
|
||||
await expect(gifCard).toBeVisible({ timeout: 10_000 });
|
||||
await gifCard.click();
|
||||
}
|
||||
|
||||
getMessageItemByText(text: string): Locator {
|
||||
return this.messageItems.filter({
|
||||
has: this.page.getByText(text, { exact: false })
|
||||
}).last();
|
||||
}
|
||||
|
||||
getMessageImageByAlt(altText: string): Locator {
|
||||
return this.page.locator(`[data-message-id] img[alt="${altText}"]`).last();
|
||||
}
|
||||
|
||||
async expectMessageImageLoaded(altText: string): Promise<void> {
|
||||
const image = this.getMessageImageByAlt(altText);
|
||||
|
||||
await expect(image).toBeVisible({ timeout: 20_000 });
|
||||
await expect.poll(async () =>
|
||||
image.evaluate((element) => {
|
||||
const img = element as HTMLImageElement;
|
||||
|
||||
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
|
||||
}), {
|
||||
timeout: 20_000,
|
||||
message: `Image ${altText} should fully load in chat`
|
||||
}).toBe(true);
|
||||
}
|
||||
|
||||
getEmbedCardByTitle(title: string): Locator {
|
||||
return this.page.locator('app-chat-link-embed').filter({
|
||||
has: this.page.getByText(title, { exact: true })
|
||||
}).last();
|
||||
}
|
||||
|
||||
async editOwnMessage(originalText: string, updatedText: string): Promise<void> {
|
||||
const messageItem = this.getMessageItemByText(originalText);
|
||||
const editButton = messageItem.locator('button:has(ng-icon[name="lucideEdit"])').first();
|
||||
const editTextarea = this.page.locator('textarea.edit-textarea').first();
|
||||
const saveButton = this.page.locator('button:has(ng-icon[name="lucideCheck"])').first();
|
||||
|
||||
await expect(messageItem).toBeVisible({ timeout: 15_000 });
|
||||
await messageItem.hover();
|
||||
await editButton.click();
|
||||
await expect(editTextarea).toBeVisible({ timeout: 10_000 });
|
||||
await editTextarea.fill(updatedText);
|
||||
await saveButton.click();
|
||||
}
|
||||
|
||||
async deleteOwnMessage(text: string): Promise<void> {
|
||||
const messageItem = this.getMessageItemByText(text);
|
||||
const deleteButton = messageItem.locator('button:has(ng-icon[name="lucideTrash2"])').first();
|
||||
|
||||
await expect(messageItem).toBeVisible({ timeout: 15_000 });
|
||||
await messageItem.hover();
|
||||
await deleteButton.click();
|
||||
}
|
||||
}
|
||||
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, '\\$&');
|
||||
}
|
||||
29
e2e/pages/login.page.ts
Normal file
29
e2e/pages/login.page.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type Page, type Locator } from '@playwright/test';
|
||||
|
||||
export class LoginPage {
|
||||
readonly usernameInput: Locator;
|
||||
readonly passwordInput: Locator;
|
||||
readonly serverSelect: Locator;
|
||||
readonly submitButton: Locator;
|
||||
readonly errorText: Locator;
|
||||
readonly registerLink: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.usernameInput = page.locator('#login-username');
|
||||
this.passwordInput = page.locator('#login-password');
|
||||
this.serverSelect = page.locator('#login-server');
|
||||
this.submitButton = page.getByRole('button', { name: 'Login' });
|
||||
this.errorText = page.locator('.text-destructive');
|
||||
this.registerLink = page.getByRole('button', { name: 'Register' });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/login');
|
||||
}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
await this.usernameInput.fill(username);
|
||||
await this.passwordInput.fill(password);
|
||||
await this.submitButton.click();
|
||||
}
|
||||
}
|
||||
45
e2e/pages/register.page.ts
Normal file
45
e2e/pages/register.page.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { expect, type Page, type Locator } from '@playwright/test';
|
||||
|
||||
export class RegisterPage {
|
||||
readonly usernameInput: Locator;
|
||||
readonly displayNameInput: Locator;
|
||||
readonly passwordInput: Locator;
|
||||
readonly serverSelect: Locator;
|
||||
readonly submitButton: Locator;
|
||||
readonly errorText: Locator;
|
||||
readonly loginLink: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.usernameInput = page.locator('#register-username');
|
||||
this.displayNameInput = page.locator('#register-display-name');
|
||||
this.passwordInput = page.locator('#register-password');
|
||||
this.serverSelect = page.locator('#register-server');
|
||||
this.submitButton = page.getByRole('button', { name: 'Create Account' });
|
||||
this.errorText = page.locator('.text-destructive');
|
||||
this.loginLink = page.getByRole('button', { name: 'Login' });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/register', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
try {
|
||||
await expect(this.usernameInput).toBeVisible({ timeout: 10_000 });
|
||||
} catch {
|
||||
// Angular router may redirect to /login on first load; click through.
|
||||
const registerLink = this.page.getByRole('link', { name: 'Register' })
|
||||
.or(this.page.getByText('Register'));
|
||||
|
||||
await registerLink.first().click();
|
||||
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
await expect(this.submitButton).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async register(username: string, displayName: string, password: string) {
|
||||
await this.usernameInput.fill(username);
|
||||
await this.displayNameInput.fill(displayName);
|
||||
await this.passwordInput.fill(password);
|
||||
await this.submitButton.click();
|
||||
}
|
||||
}
|
||||
65
e2e/pages/server-search.page.ts
Normal file
65
e2e/pages/server-search.page.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
type Page,
|
||||
type Locator,
|
||||
expect
|
||||
} from '@playwright/test';
|
||||
|
||||
export class ServerSearchPage {
|
||||
readonly searchInput: Locator;
|
||||
readonly createServerButton: Locator;
|
||||
readonly settingsButton: Locator;
|
||||
|
||||
// Create server dialog
|
||||
readonly serverNameInput: Locator;
|
||||
readonly serverDescriptionInput: Locator;
|
||||
readonly serverTopicInput: Locator;
|
||||
readonly signalEndpointSelect: Locator;
|
||||
readonly privateCheckbox: Locator;
|
||||
readonly serverPasswordInput: Locator;
|
||||
readonly dialogCreateButton: Locator;
|
||||
readonly dialogCancelButton: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.searchInput = page.getByPlaceholder('Search servers...');
|
||||
this.createServerButton = page.getByRole('button', { name: 'Create New Server' });
|
||||
this.settingsButton = page.locator('button[title="Settings"]');
|
||||
|
||||
// Create dialog elements
|
||||
this.serverNameInput = page.locator('#create-server-name');
|
||||
this.serverDescriptionInput = page.locator('#create-server-description');
|
||||
this.serverTopicInput = page.locator('#create-server-topic');
|
||||
this.signalEndpointSelect = page.locator('#create-server-signal-endpoint');
|
||||
this.privateCheckbox = page.locator('#private');
|
||||
this.serverPasswordInput = page.locator('#create-server-password');
|
||||
this.dialogCreateButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Create' });
|
||||
this.dialogCancelButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Cancel' });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/search');
|
||||
}
|
||||
|
||||
async createServer(name: string, options?: { description?: string; topic?: string }) {
|
||||
await this.createServerButton.click();
|
||||
await expect(this.serverNameInput).toBeVisible();
|
||||
await this.serverNameInput.fill(name);
|
||||
|
||||
if (options?.description) {
|
||||
await this.serverDescriptionInput.fill(options.description);
|
||||
}
|
||||
|
||||
if (options?.topic) {
|
||||
await this.serverTopicInput.fill(options.topic);
|
||||
}
|
||||
|
||||
await this.dialogCreateButton.click();
|
||||
}
|
||||
|
||||
async joinSavedRoom(name: string) {
|
||||
await this.page.getByRole('button', { name }).click();
|
||||
}
|
||||
|
||||
async joinServerFromSearch(name: string) {
|
||||
await this.page.locator('button', { hasText: name }).click();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user