fix: improve plugins functionality with server management

This commit is contained in:
2026-04-29 20:33:54 +02:00
parent b8f6d58d99
commit fa2cca6fa4
82 changed files with 1708 additions and 303 deletions

View File

@@ -69,6 +69,7 @@ function applySeededEndpointStorageState(storageState: SeededEndpointStorageStat
'toju-primary',
'toju-sweden'
]));
storage.setItem('metoyou_general_settings', generalSettings);
if (currentUserId) {

View File

@@ -10,7 +10,9 @@ export class LoginPage {
readonly registerLink: Locator;
constructor(private page: Page) {
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]').first();
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]')
.first();
this.usernameInput = page.locator('#login-username');
this.passwordInput = page.locator('#login-password');
this.serverSelect = page.locator('#login-server');

View File

@@ -79,12 +79,19 @@ export class ServerSearchPage {
await this.page.getByRole('button', { name }).click();
}
async joinServerFromSearch(name: string) {
async joinServerFromSearch(name: string, options: { acceptPluginDownloads?: boolean } = {}) {
await this.searchInput.fill(name);
const serverCard = this.page.locator('div[title]', { hasText: name }).first();
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.dblclick();
if (options.acceptPluginDownloads) {
const pluginConsentDialog = this.page.getByRole('dialog', { name: /uses plugins/ });
await expect(pluginConsentDialog).toBeVisible({ timeout: 20_000 });
await pluginConsentDialog.getByRole('button', { name: 'Accept and join' }).click();
}
}
}

View File

@@ -25,10 +25,7 @@ interface PersistentClient {
userDataDir: string;
}
const CLIENT_LAUNCH_ARGS = [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream'
];
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
test.describe('User session data isolation', () => {
test.describe.configure({ timeout: 240_000 });
@@ -43,6 +40,7 @@ test.describe('User session data isolation', () => {
};
const aliceServerName = `Alice Session Server ${suffix}`;
const aliceMessage = `Alice persisted message ${suffix}`;
let client: PersistentClient | null = null;
try {
@@ -82,6 +80,7 @@ test.describe('User session data isolation', () => {
const bobServerName = `Bob Private Server ${suffix}`;
const aliceMessage = `Alice history ${suffix}`;
const bobMessage = `Bob history ${suffix}`;
let client: PersistentClient | null = null;
try {
@@ -136,7 +135,7 @@ async function launchPersistentClient(userDataDir: string, testServerPort: numbe
await installTestServerEndpoint(context, testServerPort);
const page = context.pages()[0] ?? await context.newPage();
const page = context.pages()[0] ?? (await context.newPage());
return {
context,
@@ -202,6 +201,7 @@ async function createServerAndSendMessage(page: Page, serverName: string, messag
await searchPage.createServer(serverName, {
description: `User session isolation coverage for ${serverName}`
});
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
await messagesPage.sendMessage(messageText);
@@ -209,11 +209,15 @@ async function createServerAndSendMessage(page: Page, serverName: string, messag
}
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> {
const roomButton = getSavedRoomButton(page, roomName);
const railRoomButton = getRailSavedRoomButton(page, roomName);
const messagesPage = new ChatMessagesPage(page);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(railRoomButton).toBeVisible({ timeout: 20_000 });
await page.goto('/search', { waitUntil: 'domcontentloaded' });
const searchRoomButton = getSearchSavedRoomButton(page, roomName);
await expect(searchRoomButton).toBeVisible({ timeout: 20_000 });
await searchRoomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
}
@@ -230,17 +234,29 @@ async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<
}
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
await expect(getSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
await expect(getRailSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
await page.goto('/search', { waitUntil: 'domcontentloaded' });
await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
}
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
await expect(getSavedRoomButton(page, roomName)).toHaveCount(0);
await expect(getRailSavedRoomButton(page, roomName)).toHaveCount(0);
if (!page.url().includes('/search')) {
await page.goto('/search', { waitUntil: 'domcontentloaded' });
}
await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0);
}
function getSavedRoomButton(page: Page, roomName: string) {
function getRailSavedRoomButton(page: Page, roomName: string) {
return page.locator(`button[title="${roomName}"]`).first();
}
function getSearchSavedRoomButton(page: Page, roomName: string) {
return page.locator('app-server-search').getByRole('button', { name: roomName, exact: true });
}
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
let lastError: unknown;
@@ -259,11 +275,10 @@ async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts
}
}
throw lastError instanceof Error
? lastError
: new Error(`Navigation failed after ${attempts} attempts`);
throw lastError instanceof Error ? lastError : new Error(`Navigation failed after ${attempts} attempts`);
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -1,7 +1,13 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { chromium, type BrowserContext, type Locator, type Page, type Route } from '@playwright/test';
import {
chromium,
type BrowserContext,
type Locator,
type Page,
type Route
} from '@playwright/test';
import { test, expect } from '../../fixtures/multi-client';
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
@@ -31,7 +37,11 @@ interface PersistentClient {
}
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
const GIF_FRAME_MARKER = Buffer.from([0x21, 0xf9, 0x04]);
const GIF_FRAME_MARKER = Buffer.from([
0x21,
0xf9,
0x04
]);
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
const SERVER_ICON_SYNC_TIMEOUT_MS = 45_000;
@@ -77,6 +87,7 @@ test.describe('Server icon sync', () => {
await new ServerSearchPage(alice.page).createServer(serverName, {
description: 'Server icon synchronization E2E coverage'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await joinServerFromSearch(bob.page, serverName);
@@ -263,15 +274,15 @@ async function openServerSettings(page: Page, serverName: string): Promise<void>
async function openSettingsModalThroughAngularDevMode(page: Page): Promise<void> {
await page.evaluate(() => {
type SettingsModalComponentHandle = {
interface SettingsModalComponentHandle {
modal?: {
open: (page: string) => void;
};
};
type AngularDebugApi = {
}
interface AngularDebugApi {
getComponent: (element: Element) => SettingsModalComponentHandle;
applyChanges?: (component: SettingsModalComponentHandle) => void;
};
}
const host = document.querySelector('app-settings-modal');
const debugApi = (window as Window & { ng?: AngularDebugApi }).ng;
@@ -373,33 +384,33 @@ async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts
async function expectServerSettingsIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
const settingsPanel = page.locator('app-server-settings');
const image = settingsPanel.locator(`img[alt="${serverName} icon"]`).first();
const image = settingsPanel.locator('[style*="background-image"]').first();
await expectImageLoadedWithSrc(image, expectedDataUrl, 'settings server icon');
await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'settings server icon');
}
async function expectRoomHeaderIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
const channelsPanel = page.locator('app-rooms-side-panel').first();
const image = channelsPanel.locator(`img[alt="${serverName} icon"]`).first();
const image = channelsPanel.locator('[style*="background-image"]').first();
await expectImageLoadedWithSrc(image, expectedDataUrl, 'room header server icon');
await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'room header server icon');
}
async function expectRailIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
const image = page.locator(`app-servers-rail img[alt="${serverName} icon"]`).first();
const image = page.locator(`app-servers-rail button[title="${serverName}"] [style*="background-image"]`).first();
await expectImageLoadedWithSrc(image, expectedDataUrl, 'servers rail icon');
await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'servers rail icon');
}
async function expectSearchResultIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
const serverCard = page.locator('app-server-search div[title]', { hasText: serverName }).first();
const image = serverCard.locator(`img[alt="${serverName} icon"]`).first();
const image = serverCard.locator('[style*="background-image"]').first();
await expect(serverCard).toBeVisible({ timeout: 20_000 });
await expectImageLoadedWithSrc(image, expectedDataUrl, 'search result server icon');
await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'search result server icon');
}
async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string, label: string): Promise<void> {
async function expectBackgroundImageLoadedWithUrl(image: Locator, expectedDataUrl: string, label: string): Promise<void> {
await expect
.poll(
async () => {
@@ -407,14 +418,14 @@ async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string,
return null;
}
return image.getAttribute('src');
return image.evaluate((element) => getComputedStyle(element).backgroundImage);
},
{
timeout: SERVER_ICON_SYNC_TIMEOUT_MS,
message: `${label} src should update`
message: `${label} background should update`
}
)
.toBe(expectedDataUrl);
.toContain(expectedDataUrl);
await expect
.poll(
@@ -423,11 +434,23 @@ async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string,
return false;
}
return image.evaluate((element) => {
const img = element as HTMLImageElement;
return image.evaluate(
(element) =>
new Promise<boolean>((resolve) => {
const backgroundImage = getComputedStyle(element).backgroundImage;
const match = /^url\("?(.*?)"?\)$/.exec(backgroundImage);
const img = new Image();
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
});
if (!match?.[1]) {
resolve(false);
return;
}
img.onload = () => resolve(img.naturalWidth > 0 && img.naturalHeight > 0);
img.onerror = () => resolve(false);
img.src = match[1];
})
);
},
{
timeout: SERVER_ICON_SYNC_TIMEOUT_MS,
@@ -448,8 +471,22 @@ function buildGifUpload(label: string): ImageUploadPayload {
const header = baseGif.subarray(0, frameStart);
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
const commentData = Buffer.from(label, 'ascii');
const commentExtension = Buffer.concat([Buffer.from([0x21, 0xfe, commentData.length]), commentData, Buffer.from([0x00])]);
const buffer = Buffer.concat([header, commentExtension, frame, Buffer.from([0x3b])]);
const commentExtension = Buffer.concat([
Buffer.from([
0x21,
0xfe,
commentData.length
]),
commentData,
Buffer.from([0x00])
]);
const buffer = Buffer.concat([
header,
commentExtension,
frame,
frame,
Buffer.from([0x3b])
]);
const base64 = buffer.toString('base64');
return {
@@ -461,5 +498,6 @@ function buildGifUpload(label: string): ImageUploadPayload {
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -28,9 +28,7 @@ test.describe('Plugin API multi-user runtime', () => {
test('runs chat, embed, soundboard, and profile APIs between two users', async ({ createClient }) => {
const scenario = await createPluginApiScenario(createClient);
await test.step('Install the server plugin as Alice', async () => {
await installGrantAndActivatePlugin(scenario.alice.page, true);
await closeSettingsModal(scenario.alice.page);
await test.step('Alice has the server plugin active', async () => {
await expect(soundboardComposerButton(scenario.alice.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
@@ -101,10 +99,13 @@ async function createPluginApiScenario(createClient: () => Promise<Client>): Pro
const aliceRoom = new ChatRoomPage(alice.page);
await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
await installGrantAndActivatePlugin(alice.page, true);
await closeSettingsModal(alice.page);
await expect(soundboardComposerButton(alice.page)).toBeVisible({ timeout: 20_000 });
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName);
await bobSearch.joinServerFromSearch(serverName, { acceptPluginDownloads: true });
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 });
const bobRoom = new ChatRoomPage(bob.page);