diff --git a/.agents/skills/playwright-e2e/SKILL.md b/.agents/skills/playwright-e2e/SKILL.md index a9c10b6..5bbc233 100644 --- a/.agents/skills/playwright-e2e/SKILL.md +++ b/.agents/skills/playwright-e2e/SKILL.md @@ -31,14 +31,14 @@ If missing, scaffold it. See [reference/project-setup.md](./reference/project-se ## Step 2 — Identify Test Category -| Request | Category | Key Patterns | -|---------|----------|-------------| -| Login, register, invite | **Auth** | Single browser context, form interaction | -| Send message, rooms, chat UI | **Chat** | May need 2 clients for real-time sync | +| Request | Category | Key Patterns | +| ------------------------------- | ---------------- | ---------------------------------------------- | +| Login, register, invite | **Auth** | Single browser context, form interaction | +| Send message, rooms, chat UI | **Chat** | May need 2 clients for real-time sync | | Voice call, mute, deafen, audio | **Voice/WebRTC** | Multi-client, fake media, WebRTC introspection | -| Camera, video tiles | **Video** | Multi-client, fake video, stream validation | -| Screen share | **Screen Share** | Multi-client, display media mocking | -| Settings, themes | **Settings** | Single client, preference persistence | +| Camera, video tiles | **Video** | Multi-client, fake video, stream validation | +| Screen share | **Screen Share** | Multi-client, display media mocking | +| Settings, themes | **Settings** | Single client, preference persistence | For **Voice/WebRTC** and **Multi-client** tests, read [reference/multi-client-webrtc.md](./reference/multi-client-webrtc.md) immediately. @@ -52,17 +52,17 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', - timeout: 60_000, // WebRTC needs longer timeouts + timeout: 60_000, // WebRTC needs longer timeouts expect: { timeout: 10_000 }, retries: process.env.CI ? 2 : 0, - workers: 1, // Sequential — shared server state + workers: 1, // Sequential — shared server state reporter: [['html'], ['list']], use: { baseURL: 'http://localhost:4200', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'on-first-retry', - permissions: ['microphone', 'camera'], + permissions: ['microphone', 'camera'] }, projects: [ { @@ -72,28 +72,28 @@ export default defineConfig({ launchOptions: { args: [ '--use-fake-device-for-media-stream', - '--use-fake-ui-for-media-stream', + '--use-fake-ui-for-media-stream' // Feed a specific audio file as fake mic input: // '--use-file-for-fake-audio-capture=/path/to/audio.wav', - ], - }, - }, - }, + ] + } + } + } ], webServer: [ { command: 'cd server && npm run dev', port: 3001, reuseExistingServer: !process.env.CI, - timeout: 30_000, + timeout: 30_000 }, { command: 'cd toju-app && npx ng serve', port: 4200, reuseExistingServer: !process.env.CI, - timeout: 60_000, - }, - ], + timeout: 60_000 + } + ] }); ``` @@ -125,14 +125,14 @@ expect(text).toBe('Saved'); ### Anti-Patterns -| ❌ Don't | ✅ Do | Why | -|----------|-------|-----| -| `page.waitForTimeout(3000)` | `await expect(locator).toBeVisible()` | Hard waits are flaky | -| `expect(await el.isVisible())` | `await expect(el).toBeVisible()` | No auto-retry | -| `page.$('.btn')` | `page.getByRole('button')` | Fragile selector | -| `page.click('.submit')` | `page.getByRole('button', {name:'Submit'}).click()` | Not accessible | -| Shared state between tests | `test.beforeEach` for setup | Tests must be independent | -| `try/catch` around assertions | Let Playwright handle retries | Swallows real failures | +| ❌ Don't | ✅ Do | Why | +| ------------------------------ | --------------------------------------------------- | ------------------------- | +| `page.waitForTimeout(3000)` | `await expect(locator).toBeVisible()` | Hard waits are flaky | +| `expect(await el.isVisible())` | `await expect(el).toBeVisible()` | No auto-retry | +| `page.$('.btn')` | `page.getByRole('button')` | Fragile selector | +| `page.click('.submit')` | `page.getByRole('button', {name:'Submit'}).click()` | Not accessible | +| Shared state between tests | `test.beforeEach` for setup | Tests must be independent | +| `try/catch` around assertions | Let Playwright handle retries | Swallows real failures | ### Test Structure @@ -191,14 +191,14 @@ export class LoginPage { **Key pages to model (match `app.routes.ts`):** -| Route | Page Object | Component | -|-------|-------------|-----------| -| `/login` | `LoginPage` | `LoginComponent` | -| `/register` | `RegisterPage` | `RegisterComponent` | -| `/search` | `ServerSearchPage` | `ServerSearchComponent` | -| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` | -| `/settings` | `SettingsPage` | `SettingsComponent` | -| `/invite/:inviteId` | `InvitePage` | `InviteComponent` | +| Route | Page Object | Component | +| ------------------- | ------------------ | ----------------------- | +| `/login` | `LoginPage` | `LoginComponent` | +| `/register` | `RegisterPage` | `RegisterComponent` | +| `/search` | `ServerSearchPage` | `ServerSearchComponent` | +| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` | +| `/settings` | `SettingsPage` | `SettingsComponent` | +| `/invite/:inviteId` | `InvitePage` | `InviteComponent` | ## Step 5 — MetoYou App Architecture Context @@ -206,35 +206,35 @@ The agent writing tests MUST understand these domain boundaries: ### Voice/WebRTC Stack -| Layer | What It Does | Test Relevance | -|-------|-------------|----------------| -| `VoiceConnectionFacade` | High-level voice API (connect/disconnect/mute/deafen) | State signals to assert against | -| `VoiceSessionFacade` | Session lifecycle, workspace layout | UI mode changes | -| `VoiceActivityService` | Speaking detection (RMS threshold 0.015) | `isSpeaking()` signal validation | -| `VoicePlaybackService` | Per-peer GainNode (0–200% volume) | Volume level assertions | -| `PeerConnectionManager` | RTCPeerConnection lifecycle | Connection state introspection | -| `MediaManager` | getUserMedia, mute, gain chain | Track state validation | -| `SignalingManager` | WebSocket per signal URL | Connection establishment | +| Layer | What It Does | Test Relevance | +| ----------------------- | ----------------------------------------------------- | -------------------------------- | +| `VoiceConnectionFacade` | High-level voice API (connect/disconnect/mute/deafen) | State signals to assert against | +| `VoiceSessionFacade` | Session lifecycle, workspace layout | UI mode changes | +| `VoiceActivityService` | Speaking detection (RMS threshold 0.015) | `isSpeaking()` signal validation | +| `VoicePlaybackService` | Per-peer GainNode (0–200% volume) | Volume level assertions | +| `PeerConnectionManager` | RTCPeerConnection lifecycle | Connection state introspection | +| `MediaManager` | getUserMedia, mute, gain chain | Track state validation | +| `SignalingManager` | WebSocket per signal URL | Connection establishment | ### Voice UI Components -| Component | Selector | Contains | -|-----------|----------|----------| -| `VoiceWorkspaceComponent` | `app-voice-workspace` | Stream tiles, layout | -| `VoiceControlsComponent` | `app-voice-controls` | Mute, camera, screen share, hang-up buttons | -| `FloatingVoiceControlsComponent` | `app-floating-voice-controls` | Floating variant of controls | -| `VoiceWorkspaceStreamTileComponent` | `app-voice-workspace-stream-tile` | Per-peer audio/video tile | +| Component | Selector | Contains | +| ----------------------------------- | --------------------------------- | ------------------------------------------- | +| `VoiceWorkspaceComponent` | `app-voice-workspace` | Stream tiles, layout | +| `VoiceControlsComponent` | `app-voice-controls` | Mute, camera, screen share, hang-up buttons | +| `FloatingVoiceControlsComponent` | `app-floating-voice-controls` | Floating variant of controls | +| `VoiceWorkspaceStreamTileComponent` | `app-voice-workspace-stream-tile` | Per-peer audio/video tile | ### Voice UI Icons (Lucide) -| Icon | Meaning | -|------|---------| -| `lucideMic` / `lucideMicOff` | Mute toggle | -| `lucideVideo` / `lucideVideoOff` | Camera toggle | -| `lucideMonitor` / `lucideMonitorOff` | Screen share toggle | -| `lucidePhoneOff` | Hang up / disconnect | -| `lucideHeadphones` | Deafen state | -| `lucideVolume2` / `lucideVolumeX` | Volume indicator | +| Icon | Meaning | +| ------------------------------------ | -------------------- | +| `lucideMic` / `lucideMicOff` | Mute toggle | +| `lucideVideo` / `lucideVideoOff` | Camera toggle | +| `lucideMonitor` / `lucideMonitorOff` | Screen share toggle | +| `lucidePhoneOff` | Hang up / disconnect | +| `lucideHeadphones` | Deafen state | +| `lucideVolume2` / `lucideVolumeX` | Volume indicator | ### Server & Signaling @@ -256,6 +256,7 @@ After generating any test: ``` If the test involves WebRTC, always verify: + - Fake media flags are set in config - Timeouts are sufficient (60s+ for connection establishment) - `workers: 1` if tests share server state @@ -276,7 +277,7 @@ npx playwright codegen http://localhost:4200 # Record test ## Reference Files -| File | When to Read | -|------|-------------| +| File | When to Read | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------ | | [reference/multi-client-webrtc.md](./reference/multi-client-webrtc.md) | Voice/video/WebRTC tests, multi-browser contexts, audio validation | -| [reference/project-setup.md](./reference/project-setup.md) | First-time scaffold, dependency installation, config creation | +| [reference/project-setup.md](./reference/project-setup.md) | First-time scaffold, dependency installation, config creation | 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..32e106e --- /dev/null +++ b/e2e/fixtures/multi-client.ts @@ -0,0 +1,54 @@ +import { + test as base, + chromium, + type Page, + type BrowserContext, + type Browser +} from '@playwright/test'; +import { installTestServerEndpoint } from '../helpers/seed-test-endpoint'; + +export interface Client { + page: Page; + context: BrowserContext; +} + +interface 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/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(); }