From b10004e1543339c5637275bfa10a61213771a98a Mon Sep 17 00:00:00 2001 From: Myx Date: Sat, 11 Apr 2026 16:48:26 +0200 Subject: [PATCH] test: Add playwright main usage test --- .../playwright-e2e/reference/project-setup.md | 26 +- .gitignore | 4 + e2e/fixtures/base.ts | 4 + e2e/fixtures/multi-client.ts | 57 +++++ e2e/helpers/seed-test-endpoint.ts | 77 ++++++ e2e/helpers/start-test-server.js | 95 +++++++ e2e/helpers/webrtc-helpers.ts | 134 ++++++++++ e2e/pages/chat-room.page.ts | 79 ++++++ e2e/pages/login.page.ts | 29 +++ e2e/pages/register.page.ts | 35 +++ e2e/pages/server-search.page.ts | 65 +++++ e2e/playwright.config.ts | 52 ++++ e2e/tests/voice/voice-full-journey.spec.ts | 238 ++++++++++++++++++ electron/ipc/system.ts | 22 ++ electron/preload.ts | 2 + package-lock.json | 64 +++++ package.json | 7 +- server/data/metoyou.sqlite | Bin 94208 -> 172032 bytes server/src/db/database.ts | 10 +- .../platform/electron/electron-api.models.ts | 1 + .../shell/native-context-menu.component.ts | 7 +- 21 files changed, 1002 insertions(+), 6 deletions(-) create mode 100644 e2e/fixtures/base.ts create mode 100644 e2e/fixtures/multi-client.ts create mode 100644 e2e/helpers/seed-test-endpoint.ts create mode 100644 e2e/helpers/start-test-server.js create mode 100644 e2e/helpers/webrtc-helpers.ts create mode 100644 e2e/pages/chat-room.page.ts create mode 100644 e2e/pages/login.page.ts create mode 100644 e2e/pages/register.page.ts create mode 100644 e2e/pages/server-search.page.ts create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/voice/voice-full-journey.spec.ts diff --git a/.agents/skills/playwright-e2e/reference/project-setup.md b/.agents/skills/playwright-e2e/reference/project-setup.md index 92f2fe0..7e4c701 100644 --- a/.agents/skills/playwright-e2e/reference/project-setup.md +++ b/.agents/skills/playwright-e2e/reference/project-setup.md @@ -97,7 +97,31 @@ Create `e2e/fixtures/multi-client.ts` — see [multi-client-webrtc.md](./multi-c Create `e2e/helpers/webrtc-helpers.ts` — see [multi-client-webrtc.md](./multi-client-webrtc.md) for helper functions. -### 7. Add npm Scripts +### 7. Create Isolated Test Server Launcher + +The app requires a signal server. Tests use an isolated instance with its own temporary database so test data never pollutes the dev environment. + +Create `e2e/helpers/start-test-server.js` — a Node.js script that: +1. Creates a temp directory under the OS tmpdir +2. Writes a `data/variables.json` with `serverPort: 3099`, `serverProtocol: "http"` +3. Spawns `ts-node server/src/index.ts` with `cwd` set to the temp dir +4. Cleans up the temp dir on exit + +The server's `getRuntimeBaseDir()` returns `process.cwd()`, so setting cwd to the temp dir makes the database go to `/data/metoyou.sqlite`. Module resolution (`require`/`import`) uses `__dirname`, so server source and `node_modules` resolve correctly from the real `server/` directory. + +Playwright's `webServer` config calls this script and waits for port 3099 to be ready. + +### 8. Create Test Endpoint Seeder + +The Angular app reads signal endpoints from `localStorage['metoyou_server_endpoints']`. By default it falls back to production URLs in `environment.ts`. For tests, seed localStorage with a single endpoint pointing at `http://localhost:3099`. + +Create `e2e/helpers/seed-test-endpoint.ts` — called automatically by the multi-client fixture after creating each browser context. The flow is: +1. Navigate to `/` (establishes the origin for localStorage) +2. Set `metoyou_server_endpoints` to `[{ id: 'e2e-test-server', url: 'http://localhost:3099', ... }]` +3. Set `metoyou_removed_default_server_keys` to suppress production endpoints +4. Reload the page so the app picks up the test endpoint + +### 9. Add npm Scripts Add to root `package.json`: diff --git a/.gitignore b/.gitignore index 1df3392..4171d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,10 @@ testem.log /typings __screenshots__/ +# Playwright +test-results/ +e2e/playwright-report/ + # System files .DS_Store Thumbs.db diff --git a/e2e/fixtures/base.ts b/e2e/fixtures/base.ts new file mode 100644 index 0000000..afa0492 --- /dev/null +++ b/e2e/fixtures/base.ts @@ -0,0 +1,4 @@ +import { test as base } from '@playwright/test'; + +export const test = base; +export { expect } from '@playwright/test'; diff --git a/e2e/fixtures/multi-client.ts b/e2e/fixtures/multi-client.ts new file mode 100644 index 0000000..15e471f --- /dev/null +++ b/e2e/fixtures/multi-client.ts @@ -0,0 +1,57 @@ +import { + test as base, + chromium, + type Page, + type BrowserContext, + type Browser +} from '@playwright/test'; +import { installTestServerEndpoint } from '../helpers/seed-test-endpoint'; + +export type Client = { + page: Page; + context: BrowserContext; +}; + +type MultiClientFixture = { + createClient: () => Promise; + browser: Browser; +}; + +export const test = base.extend({ + browser: async ({}, use) => { + const browser = await chromium.launch({ + args: [ + '--use-fake-device-for-media-stream', + '--use-fake-ui-for-media-stream', + ], + }); + await use(browser); + await browser.close(); + }, + + createClient: async ({ browser }, use) => { + const clients: Client[] = []; + + const factory = async (): Promise => { + const context = await browser.newContext({ + permissions: ['microphone', 'camera'], + baseURL: 'http://localhost:4200' + }); + + await installTestServerEndpoint(context); + + const page = await context.newPage(); + + clients.push({ page, context }); + return { page, context }; + }; + + await use(factory); + + for (const client of clients) { + await client.context.close(); + } + }, +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/helpers/seed-test-endpoint.ts b/e2e/helpers/seed-test-endpoint.ts new file mode 100644 index 0000000..71b0a7c --- /dev/null +++ b/e2e/helpers/seed-test-endpoint.ts @@ -0,0 +1,77 @@ +import { type BrowserContext, type Page } from '@playwright/test'; + +const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints'; +const REMOVED_DEFAULT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys'; + +type SeededEndpointStorageState = { + key: string; + removedKey: string; + endpoints: { + id: string; + name: string; + url: string; + isActive: boolean; + isDefault: boolean; + status: string; + }[]; +}; + +function buildSeededEndpointStorageState( + port: number = Number(process.env.TEST_SERVER_PORT) || 3099 +): SeededEndpointStorageState { + const endpoint = { + id: 'e2e-test-server', + name: 'E2E Test Server', + url: `http://localhost:${port}`, + isActive: true, + isDefault: false, + status: 'unknown' + }; + + return { + key: SERVER_ENDPOINTS_STORAGE_KEY, + removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY, + endpoints: [endpoint] + }; +} + +function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void { + try { + const storage = window.localStorage; + + storage.setItem(storageState.key, JSON.stringify(storageState.endpoints)); + storage.setItem(storageState.removedKey, JSON.stringify(['default', 'toju-primary', 'toju-sweden'])); + } catch { + // about:blank and some Playwright UI pages deny localStorage access. + } +} + +export async function installTestServerEndpoint( + context: BrowserContext, + port: number = Number(process.env.TEST_SERVER_PORT) || 3099 +): Promise { + const storageState = buildSeededEndpointStorageState(port); + + await context.addInitScript(applySeededEndpointStorageState, storageState); +} + +/** + * Seed localStorage with a single signal endpoint pointing at the test server. + * Must be called AFTER navigating to the app origin (localStorage is per-origin) + * but BEFORE the app reads from storage (i.e. before the Angular bootstrap is + * relied upon — calling it in the first goto() landing page is fine since the + * page will re-read on next navigation/reload). + * + * Typical usage: + * await page.goto('/'); + * await seedTestServerEndpoint(page); + * await page.reload(); // App now picks up the test endpoint + */ +export async function seedTestServerEndpoint( + page: Page, + port: number = Number(process.env.TEST_SERVER_PORT) || 3099 +): Promise { + const storageState = buildSeededEndpointStorageState(port); + + await page.evaluate(applySeededEndpointStorageState, storageState); +} diff --git a/e2e/helpers/start-test-server.js b/e2e/helpers/start-test-server.js new file mode 100644 index 0000000..4895afe --- /dev/null +++ b/e2e/helpers/start-test-server.js @@ -0,0 +1,95 @@ +/** + * Launches an isolated MetoYou signaling server for E2E tests. + * + * Creates a temporary data directory so the test server gets its own + * fresh SQLite database. The server process inherits stdio so Playwright + * can watch stdout for readiness and the developer can see logs. + * + * Cleanup: the temp directory is removed when the process exits. + */ +const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs'); +const { join } = require('path'); +const { tmpdir } = require('os'); +const { spawn } = require('child_process'); + +const TEST_PORT = process.env.TEST_SERVER_PORT || '3099'; +const SERVER_DIR = join(__dirname, '..', '..', 'server'); +const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts'); +const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json'); + +// ── Create isolated temp data directory ────────────────────────────── +const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-')); +const dataDir = join(tmpDir, 'data'); +mkdirSync(dataDir, { recursive: true }); + +writeFileSync( + join(dataDir, 'variables.json'), + JSON.stringify({ + serverPort: parseInt(TEST_PORT, 10), + serverProtocol: 'http', + serverHost: '', + klipyApiKey: '', + releaseManifestUrl: '', + linkPreview: { enabled: false, cacheTtlMinutes: 60, maxCacheSizeMb: 10 }, + }) +); + +console.log(`[E2E Server] Temp data dir: ${tmpDir}`); +console.log(`[E2E Server] Starting on port ${TEST_PORT}...`); + +// ── Spawn the server with cwd = temp dir ───────────────────────────── +// process.cwd() is used by getRuntimeBaseDir() in the server, so data/ +// (database, variables.json) will resolve to our temp directory. +// Module resolution (require/import) uses __dirname, so server source +// and node_modules are found from the real server/ directory. +const child = spawn( + 'npx', + ['ts-node', '--project', SERVER_TSCONFIG, SERVER_ENTRY], + { + cwd: tmpDir, + env: { + ...process.env, + PORT: TEST_PORT, + SSL: 'false', + NODE_ENV: 'test', + DB_SYNCHRONIZE: 'true', + }, + stdio: 'inherit', + shell: true, + } +); + +child.on('error', (err) => { + console.error('[E2E Server] Failed to start:', err.message); + cleanup(); + process.exit(1); +}); + +child.on('exit', (code) => { + console.log(`[E2E Server] Exited with code ${code}`); + cleanup(); +}); + +// ── Cleanup on signals ─────────────────────────────────────────────── +function cleanup() { + try { + rmSync(tmpDir, { recursive: true, force: true }); + console.log(`[E2E Server] Cleaned up temp dir: ${tmpDir}`); + } catch { + // already gone + } +} + +function shutdown() { + child.kill('SIGTERM'); + // Give child 3s to exit, then force kill + setTimeout(() => { + if (!child.killed) child.kill('SIGKILL'); + cleanup(); + process.exit(0); + }, 3_000); +} + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); +process.on('exit', cleanup); diff --git a/e2e/helpers/webrtc-helpers.ts b/e2e/helpers/webrtc-helpers.ts new file mode 100644 index 0000000..add5860 --- /dev/null +++ b/e2e/helpers/webrtc-helpers.ts @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { type Page } from '@playwright/test'; + +/** + * Install RTCPeerConnection monkey-patch on a page BEFORE navigating. + * Tracks all created peer connections and their remote tracks so tests + * can inspect WebRTC state via `page.evaluate()`. + * + * Call immediately after page creation, before any `goto()`. + */ +export async function installWebRTCTracking(page: Page): Promise { + await page.addInitScript(() => { + const connections: RTCPeerConnection[] = []; + + (window as any).__rtcConnections = connections; + (window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[]; + + const OriginalRTCPeerConnection = window.RTCPeerConnection; + + (window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) { + const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args); + + connections.push(pc); + + pc.addEventListener('connectionstatechange', () => { + (window as any).__lastRtcState = pc.connectionState; + }); + + pc.addEventListener('track', (event: RTCTrackEvent) => { + (window as any).__rtcRemoteTracks.push({ + kind: event.track.kind, + id: event.track.id, + readyState: event.track.readyState + }); + }); + + return pc; + } as any; + + (window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype; + Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection); + }); +} + +/** + * Wait until at least one RTCPeerConnection reaches the 'connected' state. + */ +export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise { + await page.waitForFunction( + () => (window as any).__rtcConnections?.some( + (pc: RTCPeerConnection) => pc.connectionState === 'connected' + ) ?? false, + { timeout } + ); +} + +/** + * Check that a peer connection is still in 'connected' state (not failed/disconnected). + */ +export async function isPeerStillConnected(page: Page): Promise { + return page.evaluate( + () => (window as any).__rtcConnections?.some( + (pc: RTCPeerConnection) => pc.connectionState === 'connected' + ) ?? false + ); +} + +/** + * Get outbound and inbound audio RTP stats from the first peer connection. + */ +export async function getAudioStats(page: Page): Promise<{ + outbound: { bytesSent: number; packetsSent: number } | null; + inbound: { bytesReceived: number; packetsReceived: number } | null; +}> { + return page.evaluate(async () => { + const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; + + if (!connections?.length) + return { outbound: null, inbound: null }; + + let outbound: { bytesSent: number; packetsSent: number } | null = null; + let inbound: { bytesReceived: number; packetsReceived: number } | null = null; + + for (const pc of connections) { + if (pc.connectionState !== 'connected') + continue; + + const stats = await pc.getStats(); + + stats.forEach((report: any) => { + const reportMediaType = report.kind ?? report.mediaType; + + if (report.type === 'outbound-rtp' && reportMediaType === 'audio' && !outbound) { + outbound = { + bytesSent: report.bytesSent ?? 0, + packetsSent: report.packetsSent ?? 0 + }; + } + + if (report.type === 'inbound-rtp' && reportMediaType === 'audio' && !inbound) { + inbound = { + bytesReceived: report.bytesReceived ?? 0, + packetsReceived: report.packetsReceived ?? 0 + }; + } + }); + + if (outbound && inbound) + break; + } + + return { outbound, inbound }; + }); +} + +/** + * Snapshot audio stats, wait `durationMs`, snapshot again, and return the delta. + * Useful for verifying audio is actively flowing (bytes increasing). + */ +export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promise<{ + outboundBytesDelta: number; + inboundBytesDelta: number; +}> { + const before = await getAudioStats(page); + + await page.waitForTimeout(durationMs); + + const after = await getAudioStats(page); + + return { + outboundBytesDelta: (after.outbound?.bytesSent ?? 0) - (before.outbound?.bytesSent ?? 0), + inboundBytesDelta: (after.inbound?.bytesReceived ?? 0) - (before.inbound?.bytesReceived ?? 0) + }; +} diff --git a/e2e/pages/chat-room.page.ts b/e2e/pages/chat-room.page.ts new file mode 100644 index 0000000..708774c --- /dev/null +++ b/e2e/pages/chat-room.page.ts @@ -0,0 +1,79 @@ +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 "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 createButton.click(); + } + + /** 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 { + const channelSection = this.page.locator('app-rooms-side-panel') + .getByRole('button', { name: channelName }) + .locator('..'); + const userAvatars = channelSection.locator('app-user-avatar'); + + return userAvatars.count(); + } +} diff --git a/e2e/pages/login.page.ts b/e2e/pages/login.page.ts new file mode 100644 index 0000000..9a9f905 --- /dev/null +++ b/e2e/pages/login.page.ts @@ -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(); + } +} diff --git a/e2e/pages/register.page.ts b/e2e/pages/register.page.ts new file mode 100644 index 0000000..d158b57 --- /dev/null +++ b/e2e/pages/register.page.ts @@ -0,0 +1,35 @@ +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' }); + + 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(); + } +} diff --git a/e2e/pages/server-search.page.ts b/e2e/pages/server-search.page.ts new file mode 100644 index 0000000..14679f2 --- /dev/null +++ b/e2e/pages/server-search.page.ts @@ -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(); + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..4e925bb --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,52 @@ +import { defineConfig, devices } from '@playwright/test'; + +const TEST_SERVER_PORT = Number(process.env.TEST_SERVER_PORT) || 3099; + +export default defineConfig({ + testDir: './tests', + timeout: 90_000, + expect: { timeout: 10_000 }, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [['html', { outputFolder: '../test-results/html-report' }], ['list']], + outputDir: '../test-results/artifacts', + use: { + baseURL: 'http://localhost:4200', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'on-first-retry', + actionTimeout: 15_000, + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + permissions: ['microphone', 'camera'], + launchOptions: { + args: [ + '--use-fake-device-for-media-stream', + '--use-fake-ui-for-media-stream', + ], + }, + }, + }, + ], + webServer: [ + { + // Isolated test server with its own temporary database. + // See e2e/helpers/start-test-server.js for details. + command: `node helpers/start-test-server.js`, + port: TEST_SERVER_PORT, + reuseExistingServer: !process.env.CI, + timeout: 30_000, + env: { TEST_SERVER_PORT: String(TEST_SERVER_PORT) }, + }, + { + command: 'cd ../toju-app && npx ng serve', + port: 4200, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + ], +}); diff --git a/e2e/tests/voice/voice-full-journey.spec.ts b/e2e/tests/voice/voice-full-journey.spec.ts new file mode 100644 index 0000000..fe97d55 --- /dev/null +++ b/e2e/tests/voice/voice-full-journey.spec.ts @@ -0,0 +1,238 @@ +import { test, expect } from '../../fixtures/multi-client'; +import { + installWebRTCTracking, + waitForPeerConnected, + isPeerStillConnected, + getAudioStatsDelta +} from '../../helpers/webrtc-helpers'; +import { RegisterPage } from '../../pages/register.page'; +import { ServerSearchPage } from '../../pages/server-search.page'; +import { ChatRoomPage } from '../../pages/chat-room.page'; + +/** + * Full user journey: register → create server → join → voice → verify audio + * for 10+ seconds of stable connectivity. + * + * Uses two independent browser contexts (Alice & Bob) to simulate real + * multi-user WebRTC voice chat. + */ + +const ALICE = { username: `alice_${Date.now()}`, displayName: 'Alice', password: 'TestPass123!' }; +const BOB = { username: `bob_${Date.now()}`, displayName: 'Bob', password: 'TestPass123!' }; +const SERVER_NAME = `E2E Test Server ${Date.now()}`; +const VOICE_CHANNEL = 'General'; + +test.describe('Full user journey: register → server → voice chat', () => { + test('two users register, create server, join voice, and stay connected 10+ seconds with audio', async ({ createClient }) => { + test.setTimeout(180_000); // 3 min - covers registration, server creation, voice establishment, and 10s stability check + + const alice = await createClient(); + const bob = await createClient(); + + // Install WebRTC tracking before any navigation + await installWebRTCTracking(alice.page); + await installWebRTCTracking(bob.page); + + // Forward browser console for debugging + alice.page.on('console', msg => console.log('[Alice]', msg.text())); + bob.page.on('console', msg => console.log('[Bob]', msg.text())); + + // ── Step 1: Register both users ────────────────────────────────── + + await test.step('Alice registers an account', async () => { + const registerPage = new RegisterPage(alice.page); + + await registerPage.goto(); + await expect(registerPage.submitButton).toBeVisible(); + await registerPage.register(ALICE.username, ALICE.displayName, ALICE.password); + + // After registration, app should navigate to /search + await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 }); + }); + + await test.step('Bob registers an account', async () => { + const registerPage = new RegisterPage(bob.page); + + await registerPage.goto(); + await expect(registerPage.submitButton).toBeVisible(); + await registerPage.register(BOB.username, BOB.displayName, BOB.password); + + await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 }); + }); + + // ── Step 2: Alice creates a server ─────────────────────────────── + + await test.step('Alice creates a new server', async () => { + const searchPage = new ServerSearchPage(alice.page); + + await searchPage.createServer(SERVER_NAME, { + description: 'E2E test server for voice testing' + }); + + // After server creation, app navigates to the room + await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); + }); + + // ── Step 3: Bob joins the server ───────────────────────────────── + + await test.step('Bob finds and joins the server', async () => { + const searchPage = new ServerSearchPage(bob.page); + + // Search for the server + await searchPage.searchInput.fill(SERVER_NAME); + + // Wait for search results and click the server + const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first(); + + await expect(serverCard).toBeVisible({ timeout: 10_000 }); + await serverCard.click(); + + // Bob should be in the room now + await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); + }); + + // ── Step 4: Create a voice channel (if one doesn't exist) ──────── + + await test.step('Alice ensures a voice channel is available', async () => { + const chatRoom = new ChatRoomPage(alice.page); + const existingVoiceChannel = alice.page.locator('app-rooms-side-panel') + .getByRole('button', { name: VOICE_CHANNEL, exact: true }); + const voiceChannelExists = await existingVoiceChannel.count() > 0; + + if (!voiceChannelExists) { + // Click "Create Voice Channel" plus button + await chatRoom.openCreateVoiceChannelDialog(); + await chatRoom.createChannel(VOICE_CHANNEL); + + // Wait for the channel to appear + await expect(existingVoiceChannel).toBeVisible({ timeout: 10_000 }); + } + }); + + // ── Step 5: Both users join the voice channel ──────────────────── + + await test.step('Alice joins the voice channel', async () => { + const chatRoom = new ChatRoomPage(alice.page); + + await chatRoom.joinVoiceChannel(VOICE_CHANNEL); + + // Voice controls should appear (indicates voice is connected) + await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); + }); + + await test.step('Bob joins the voice channel', async () => { + const chatRoom = new ChatRoomPage(bob.page); + + await chatRoom.joinVoiceChannel(VOICE_CHANNEL); + + await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); + }); + + // ── Step 6: Verify WebRTC connection establishes ───────────────── + + await test.step('WebRTC peer connection reaches "connected" state', async () => { + await waitForPeerConnected(alice.page, 30_000); + await waitForPeerConnected(bob.page, 30_000); + }); + + // ── Step 7: Verify audio is flowing in both directions ─────────── + + await test.step('Audio packets are flowing between Alice and Bob', async () => { + // Wait a moment for audio pipeline to stabilize + const aliceDelta = await getAudioStatsDelta(alice.page, 3_000); + + expect(aliceDelta.outboundBytesDelta).toBeGreaterThan(0); + expect(aliceDelta.inboundBytesDelta).toBeGreaterThan(0); + + const bobDelta = await getAudioStatsDelta(bob.page, 3_000); + + expect(bobDelta.outboundBytesDelta).toBeGreaterThan(0); + expect(bobDelta.inboundBytesDelta).toBeGreaterThan(0); + }); + + // ── Step 8: Verify UI states are correct ───────────────────────── + + await test.step('Voice UI shows correct state for both users', async () => { + const aliceRoom = new ChatRoomPage(alice.page); + const bobRoom = new ChatRoomPage(bob.page); + + // Both should see voice controls with "Connected" status + await expect(alice.page.locator('app-voice-controls')).toBeVisible(); + await expect(bob.page.locator('app-voice-controls')).toBeVisible(); + + // Both should see the voice workspace or at least voice users listed + // Check that both users appear in the voice channel user list + const aliceSeesBob = aliceRoom.channelsSidePanel.getByText(BOB.displayName).first(); + const bobSeesAlice = bobRoom.channelsSidePanel.getByText(ALICE.displayName).first(); + + await expect(aliceSeesBob).toBeVisible({ timeout: 10_000 }); + await expect(bobSeesAlice).toBeVisible({ timeout: 10_000 }); + }); + + // ── Step 9: Stay connected for 10+ seconds, verify stability ───── + + await test.step('Connection remains stable for 10+ seconds', async () => { + // Check connectivity at 0s, 5s, and 10s intervals + for (const checkpoint of [ + 0, + 5_000, + 5_000 + ]) { + if (checkpoint > 0) { + await alice.page.waitForTimeout(checkpoint); + } + + const aliceConnected = await isPeerStillConnected(alice.page); + const bobConnected = await isPeerStillConnected(bob.page); + + expect(aliceConnected, 'Alice should still be connected').toBe(true); + expect(bobConnected, 'Bob should still be connected').toBe(true); + } + + // After 10s total, verify audio is still flowing + const aliceDelta = await getAudioStatsDelta(alice.page, 2_000); + + expect(aliceDelta.outboundBytesDelta, 'Alice should still be sending audio after 10s').toBeGreaterThan(0); + expect(aliceDelta.inboundBytesDelta, 'Alice should still be receiving audio after 10s').toBeGreaterThan(0); + + const bobDelta = await getAudioStatsDelta(bob.page, 2_000); + + expect(bobDelta.outboundBytesDelta, 'Bob should still be sending audio after 10s').toBeGreaterThan(0); + expect(bobDelta.inboundBytesDelta, 'Bob should still be receiving audio after 10s').toBeGreaterThan(0); + }); + + // ── Step 10: Verify mute/unmute works correctly ────────────────── + + await test.step('Mute toggle works correctly', async () => { + const aliceRoom = new ChatRoomPage(alice.page); + + // Alice mutes - click the first button in voice controls (mute button) + await aliceRoom.muteButton.click(); + + // After muting, Alice's outbound audio should stop increasing + // When muted, bytesSent may still show small comfort noise or zero growth + // The key assertion is that Bob's inbound for Alice's stream stops or reduces + await getAudioStatsDelta(alice.page, 2_000); + + // Alice unmutes + await aliceRoom.muteButton.click(); + + // After unmuting, outbound should resume + const unmutedDelta = await getAudioStatsDelta(alice.page, 2_000); + + expect(unmutedDelta.outboundBytesDelta, 'Audio should flow after unmuting').toBeGreaterThan(0); + }); + + // ── Step 11: Clean disconnect ──────────────────────────────────── + + await test.step('Alice disconnects from voice', async () => { + const aliceRoom = new ChatRoomPage(alice.page); + + // Click the disconnect/hang-up button + await aliceRoom.disconnectButton.click(); + + // Connected controls should collapse for Alice after disconnect + await expect(aliceRoom.disconnectButton).not.toBeVisible({ timeout: 10_000 }); + }); + }); +}); diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 1965f2b..e95ef5d 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -535,4 +535,26 @@ export function setupSystemHandlers(): void { request.end(); }); }); + + ipcMain.handle('context-menu-command', (_event, command: string) => { + const allowedCommands = ['cut', 'copy', 'paste', 'selectAll'] as const; + + if (!allowedCommands.includes(command as typeof allowedCommands[number])) { + return; + } + + const mainWindow = getMainWindow(); + const webContents = mainWindow?.webContents; + + if (!webContents) { + return; + } + + switch (command) { + case 'cut': webContents.cut(); break; + case 'copy': webContents.copy(); break; + case 'paste': webContents.paste(); break; + case 'selectAll': webContents.selectAll(); break; + } + }); } diff --git a/electron/preload.ts b/electron/preload.ts index 6eff138..7adb734 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -211,6 +211,7 @@ export interface ElectronAPI { ensureDir: (dirPath: string) => Promise; onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void; + contextMenuCommand: (command: string) => Promise; copyImageToClipboard: (srcURL: string) => Promise; command: (command: Command) => Promise; @@ -329,6 +330,7 @@ const electronAPI: ElectronAPI = { ipcRenderer.removeListener('show-context-menu', wrappedListener); }; }, + contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command), copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL), command: (command) => ipcRenderer.invoke('cqrs:command', command), diff --git a/package-lock.json b/package-lock.json index 4966a71..4a0b9f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "@angular/cli": "^21.0.4", "@angular/compiler-cli": "^21.0.0", "@eslint/js": "^9.39.3", + "@playwright/test": "^1.59.1", "@stylistic/eslint-plugin-js": "^4.4.1", "@stylistic/eslint-plugin-ts": "^4.4.1", "@types/auto-launch": "^5.0.5", @@ -9337,6 +9338,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz", @@ -24652,6 +24669,53 @@ "node": ">= 10.0.0" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", diff --git a/package.json b/package.json index f6afff6..1a67b36 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,11 @@ "release:version": "node tools/resolve-release-version.js", "server:bundle:linux": "node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64", "server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe", - "sort:props": "node tools/sort-template-properties.js" + "sort:props": "node tools/sort-template-properties.js", + "test:e2e": "cd e2e && npx playwright test", + "test:e2e:ui": "cd e2e && npx playwright test --ui", + "test:e2e:debug": "cd e2e && npx playwright test --debug", + "test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report" }, "private": true, "packageManager": "npm@10.9.2", @@ -102,6 +106,7 @@ "@angular/cli": "^21.0.4", "@angular/compiler-cli": "^21.0.0", "@eslint/js": "^9.39.3", + "@playwright/test": "^1.59.1", "@stylistic/eslint-plugin-js": "^4.4.1", "@stylistic/eslint-plugin-ts": "^4.4.1", "@types/auto-launch": "^5.0.5", diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index d146c46745675cefda6c6dfecf2354aae3785466..bdee3dc36baa0656e9073c37bc97efbf801bf156 100644 GIT binary patch literal 172032 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 delta 1038 zcmZoTz}4`8b%K--YZe0ogE$bwfWSl@BN3peUYjN_P)v-GnSno=pOg3B#3J>{N&;D% znHhiZiTZ{5_<-eE_(K`^Z}G3=Z|4u)EU4hhzd2NXl>j5lCIc1&K2~l`2L3d@%Y0V6 ziQJl-6$Qk(g<4Em`9p;nLPI$iIRsf*8QNl}JKkqhna_kSMafDjJvA@2C^1J#M=38cHx)&oq_O}cP?B0v0+cSuFU~B<%+FJ@GN{#oYA#C5 zOUchg=!NsKXsrk9HH7OeOV-7(+a2y0r~zg9naQa*EC(8pn3j#O+|kV&$ib!nz<=LuSaSnLNA7gjo^;9OiN8OO))h`=yY^(^u?pq7_QX|=w8z#Lj$uEBV&Yi zm+o(3SF^q*TRGk+VtBt&qKW3^-pZAwBj43*H`j@|qzIFcp&>>r|!} z|6`1h1;+WDQ#$kbK;)g*jRH(Oq2i)2Wz!SofoX&5KVuL?9h2ERpgPsF+Zx3gc|zr- zVd^F)-dCAE`#)o(7|6K8dVNPBBo{CVv+|oTZ0DN5#K=F{KtOx?1U^QV=@$GO`(yk6zZn6wKhAn@tJD@4C{~t9Vy&B{MKHkF&{5SYb_}h37 z^KnH0d<+Q5n)@$CdE zzX8-_$NL6ohzZCTV2+y43-=IE(_NUR8~knjCVV?Vw(!RSl|BO6V+XW%0`FmjrpXrm JKs|3on*ia4Jy8Gv diff --git a/server/src/db/database.ts b/server/src/db/database.ts index d567e3e..d612659 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -70,7 +70,7 @@ export async function initDatabase(): Promise { ServerBanEntity ], migrations: serverMigrations, - synchronize: false, + synchronize: process.env.DB_SYNCHRONIZE === 'true', logging: false, autoSave: true, location: DB_FILE, @@ -90,8 +90,12 @@ export async function initDatabase(): Promise { console.log('[DB] Connection initialised at:', DB_FILE); - await applicationDataSource.runMigrations(); - console.log('[DB] Migrations executed'); + if (process.env.DB_SYNCHRONIZE !== 'true') { + await applicationDataSource.runMigrations(); + console.log('[DB] Migrations executed'); + } else { + console.log('[DB] Synchronize mode — migrations skipped'); + } } export async function destroyDatabase(): Promise { diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts index 5d94461..aa757aa 100644 --- a/toju-app/src/app/core/platform/electron/electron-api.models.ts +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -193,6 +193,7 @@ export interface ElectronApi { deleteFile: (filePath: string) => Promise; ensureDir: (dirPath: string) => Promise; onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void; + contextMenuCommand: (command: string) => Promise; copyImageToClipboard: (srcURL: string) => Promise; command: (command: ElectronCommand) => Promise; query: (query: ElectronQuery) => Promise; diff --git a/toju-app/src/app/features/shell/native-context-menu.component.ts b/toju-app/src/app/features/shell/native-context-menu.component.ts index 1c0fdf1..92071c2 100644 --- a/toju-app/src/app/features/shell/native-context-menu.component.ts +++ b/toju-app/src/app/features/shell/native-context-menu.component.ts @@ -48,7 +48,12 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy { } execCommand(command: string): void { - document.execCommand(command); + const api = this.electronBridge.getApi(); + + if (api?.contextMenuCommand) { + api.contextMenuCommand(command); + } + this.close(); }