test: Add playwright main usage test
Some checks failed
Queue Release Build / prepare (push) Successful in 38s
Deploy Web Apps / deploy (push) Successful in 13m34s
Queue Release Build / build-linux (push) Successful in 45m20s
Queue Release Build / build-windows (push) Failing after 3h8m14s
Queue Release Build / finalize (push) Has been cancelled
Some checks failed
Queue Release Build / prepare (push) Successful in 38s
Deploy Web Apps / deploy (push) Successful in 13m34s
Queue Release Build / build-linux (push) Successful in 45m20s
Queue Release Build / build-windows (push) Failing after 3h8m14s
Queue Release Build / finalize (push) Has been cancelled
This commit is contained in:
@@ -32,7 +32,7 @@ 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 |
|
||||
| Voice call, mute, deafen, audio | **Voice/WebRTC** | Multi-client, fake media, WebRTC introspection |
|
||||
@@ -62,7 +62,7 @@ export default defineConfig({
|
||||
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
|
||||
}
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
@@ -126,7 +126,7 @@ 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 |
|
||||
@@ -192,7 +192,7 @@ export class LoginPage {
|
||||
**Key pages to model (match `app.routes.ts`):**
|
||||
|
||||
| Route | Page Object | Component |
|
||||
|-------|-------------|-----------|
|
||||
| ------------------- | ------------------ | ----------------------- |
|
||||
| `/login` | `LoginPage` | `LoginComponent` |
|
||||
| `/register` | `RegisterPage` | `RegisterComponent` |
|
||||
| `/search` | `ServerSearchPage` | `ServerSearchComponent` |
|
||||
@@ -207,7 +207,7 @@ 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 |
|
||||
@@ -219,7 +219,7 @@ The agent writing tests MUST understand these domain boundaries:
|
||||
### 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 |
|
||||
@@ -228,7 +228,7 @@ The agent writing tests MUST understand these domain boundaries:
|
||||
### Voice UI Icons (Lucide)
|
||||
|
||||
| Icon | Meaning |
|
||||
|------|---------|
|
||||
| ------------------------------------ | -------------------- |
|
||||
| `lucideMic` / `lucideMicOff` | Mute toggle |
|
||||
| `lucideVideo` / `lucideVideoOff` | Camera toggle |
|
||||
| `lucideMonitor` / `lucideMonitorOff` | Screen share toggle |
|
||||
@@ -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
|
||||
@@ -277,6 +278,6 @@ npx playwright codegen http://localhost:4200 # Record test
|
||||
## Reference Files
|
||||
|
||||
| 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 |
|
||||
|
||||
@@ -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 `<tmpdir>/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`:
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -44,6 +44,10 @@ testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
e2e/playwright-report/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
4
e2e/fixtures/base.ts
Normal file
4
e2e/fixtures/base.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { test as base } from '@playwright/test';
|
||||
|
||||
export const test = base;
|
||||
export { expect } from '@playwright/test';
|
||||
54
e2e/fixtures/multi-client.ts
Normal file
54
e2e/fixtures/multi-client.ts
Normal file
@@ -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<Client>;
|
||||
browser: Browser;
|
||||
}
|
||||
|
||||
export const test = base.extend<MultiClientFixture>({
|
||||
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<Client> => {
|
||||
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';
|
||||
77
e2e/helpers/seed-test-endpoint.ts
Normal file
77
e2e/helpers/seed-test-endpoint.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
const storageState = buildSeededEndpointStorageState(port);
|
||||
|
||||
await page.evaluate(applySeededEndpointStorageState, storageState);
|
||||
}
|
||||
95
e2e/helpers/start-test-server.js
Normal file
95
e2e/helpers/start-test-server.js
Normal file
@@ -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);
|
||||
134
e2e/helpers/webrtc-helpers.ts
Normal file
134
e2e/helpers/webrtc-helpers.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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)
|
||||
};
|
||||
}
|
||||
79
e2e/pages/chat-room.page.ts
Normal file
79
e2e/pages/chat-room.page.ts
Normal file
@@ -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<number> {
|
||||
const channelSection = this.page.locator('app-rooms-side-panel')
|
||||
.getByRole('button', { name: channelName })
|
||||
.locator('..');
|
||||
const userAvatars = channelSection.locator('app-user-avatar');
|
||||
|
||||
return userAvatars.count();
|
||||
}
|
||||
}
|
||||
29
e2e/pages/login.page.ts
Normal file
29
e2e/pages/login.page.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type Page, type Locator } from '@playwright/test';
|
||||
|
||||
export class LoginPage {
|
||||
readonly usernameInput: Locator;
|
||||
readonly passwordInput: Locator;
|
||||
readonly serverSelect: Locator;
|
||||
readonly submitButton: Locator;
|
||||
readonly errorText: Locator;
|
||||
readonly registerLink: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.usernameInput = page.locator('#login-username');
|
||||
this.passwordInput = page.locator('#login-password');
|
||||
this.serverSelect = page.locator('#login-server');
|
||||
this.submitButton = page.getByRole('button', { name: 'Login' });
|
||||
this.errorText = page.locator('.text-destructive');
|
||||
this.registerLink = page.getByRole('button', { name: 'Register' });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/login');
|
||||
}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
await this.usernameInput.fill(username);
|
||||
await this.passwordInput.fill(password);
|
||||
await this.submitButton.click();
|
||||
}
|
||||
}
|
||||
35
e2e/pages/register.page.ts
Normal file
35
e2e/pages/register.page.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
65
e2e/pages/server-search.page.ts
Normal file
65
e2e/pages/server-search.page.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
type Page,
|
||||
type Locator,
|
||||
expect
|
||||
} from '@playwright/test';
|
||||
|
||||
export class ServerSearchPage {
|
||||
readonly searchInput: Locator;
|
||||
readonly createServerButton: Locator;
|
||||
readonly settingsButton: Locator;
|
||||
|
||||
// Create server dialog
|
||||
readonly serverNameInput: Locator;
|
||||
readonly serverDescriptionInput: Locator;
|
||||
readonly serverTopicInput: Locator;
|
||||
readonly signalEndpointSelect: Locator;
|
||||
readonly privateCheckbox: Locator;
|
||||
readonly serverPasswordInput: Locator;
|
||||
readonly dialogCreateButton: Locator;
|
||||
readonly dialogCancelButton: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.searchInput = page.getByPlaceholder('Search servers...');
|
||||
this.createServerButton = page.getByRole('button', { name: 'Create New Server' });
|
||||
this.settingsButton = page.locator('button[title="Settings"]');
|
||||
|
||||
// Create dialog elements
|
||||
this.serverNameInput = page.locator('#create-server-name');
|
||||
this.serverDescriptionInput = page.locator('#create-server-description');
|
||||
this.serverTopicInput = page.locator('#create-server-topic');
|
||||
this.signalEndpointSelect = page.locator('#create-server-signal-endpoint');
|
||||
this.privateCheckbox = page.locator('#private');
|
||||
this.serverPasswordInput = page.locator('#create-server-password');
|
||||
this.dialogCreateButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Create' });
|
||||
this.dialogCancelButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Cancel' });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/search');
|
||||
}
|
||||
|
||||
async createServer(name: string, options?: { description?: string; topic?: string }) {
|
||||
await this.createServerButton.click();
|
||||
await expect(this.serverNameInput).toBeVisible();
|
||||
await this.serverNameInput.fill(name);
|
||||
|
||||
if (options?.description) {
|
||||
await this.serverDescriptionInput.fill(options.description);
|
||||
}
|
||||
|
||||
if (options?.topic) {
|
||||
await this.serverTopicInput.fill(options.topic);
|
||||
}
|
||||
|
||||
await this.dialogCreateButton.click();
|
||||
}
|
||||
|
||||
async joinSavedRoom(name: string) {
|
||||
await this.page.getByRole('button', { name }).click();
|
||||
}
|
||||
|
||||
async joinServerFromSearch(name: string) {
|
||||
await this.page.locator('button', { hasText: name }).click();
|
||||
}
|
||||
}
|
||||
52
e2e/playwright.config.ts
Normal file
52
e2e/playwright.config.ts
Normal file
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
238
e2e/tests/voice/voice-full-journey.spec.ts
Normal file
238
e2e/tests/voice/voice-full-journey.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,6 +211,7 @@ export interface ElectronAPI {
|
||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||
|
||||
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
|
||||
contextMenuCommand: (command: string) => Promise<void>;
|
||||
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||
|
||||
command: <T = unknown>(command: Command) => Promise<T>;
|
||||
@@ -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),
|
||||
|
||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function initDatabase(): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
console.log('[DB] Connection initialised at:', DB_FILE);
|
||||
|
||||
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<void> {
|
||||
|
||||
@@ -193,6 +193,7 @@ export interface ElectronApi {
|
||||
deleteFile: (filePath: string) => Promise<boolean>;
|
||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
|
||||
contextMenuCommand: (command: string) => Promise<void>;
|
||||
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||
command: <T = unknown>(command: ElectronCommand) => Promise<T>;
|
||||
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user