Compare commits
1 Commits
v1.0.113
...
b10004e154
| Author | SHA1 | Date | |
|---|---|---|---|
| b10004e154 |
@@ -31,14 +31,14 @@ If missing, scaffold it. See [reference/project-setup.md](./reference/project-se
|
|||||||
|
|
||||||
## Step 2 — Identify Test Category
|
## Step 2 — Identify Test Category
|
||||||
|
|
||||||
| Request | Category | Key Patterns |
|
| Request | Category | Key Patterns |
|
||||||
| ------------------------------- | ---------------- | ---------------------------------------------- |
|
|---------|----------|-------------|
|
||||||
| Login, register, invite | **Auth** | Single browser context, form interaction |
|
| Login, register, invite | **Auth** | Single browser context, form interaction |
|
||||||
| Send message, rooms, chat UI | **Chat** | May need 2 clients for real-time sync |
|
| 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 |
|
| Voice call, mute, deafen, audio | **Voice/WebRTC** | Multi-client, fake media, WebRTC introspection |
|
||||||
| Camera, video tiles | **Video** | Multi-client, fake video, stream validation |
|
| Camera, video tiles | **Video** | Multi-client, fake video, stream validation |
|
||||||
| Screen share | **Screen Share** | Multi-client, display media mocking |
|
| Screen share | **Screen Share** | Multi-client, display media mocking |
|
||||||
| Settings, themes | **Settings** | Single client, preference persistence |
|
| 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.
|
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({
|
export default defineConfig({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
timeout: 60_000, // WebRTC needs longer timeouts
|
timeout: 60_000, // WebRTC needs longer timeouts
|
||||||
expect: { timeout: 10_000 },
|
expect: { timeout: 10_000 },
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: 1, // Sequential — shared server state
|
workers: 1, // Sequential — shared server state
|
||||||
reporter: [['html'], ['list']],
|
reporter: [['html'], ['list']],
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:4200',
|
baseURL: 'http://localhost:4200',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
video: 'on-first-retry',
|
video: 'on-first-retry',
|
||||||
permissions: ['microphone', 'camera']
|
permissions: ['microphone', 'camera'],
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
@@ -72,28 +72,28 @@ export default defineConfig({
|
|||||||
launchOptions: {
|
launchOptions: {
|
||||||
args: [
|
args: [
|
||||||
'--use-fake-device-for-media-stream',
|
'--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:
|
// Feed a specific audio file as fake mic input:
|
||||||
// '--use-file-for-fake-audio-capture=/path/to/audio.wav',
|
// '--use-file-for-fake-audio-capture=/path/to/audio.wav',
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
webServer: [
|
webServer: [
|
||||||
{
|
{
|
||||||
command: 'cd server && npm run dev',
|
command: 'cd server && npm run dev',
|
||||||
port: 3001,
|
port: 3001,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 30_000
|
timeout: 30_000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: 'cd toju-app && npx ng serve',
|
command: 'cd toju-app && npx ng serve',
|
||||||
port: 4200,
|
port: 4200,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 60_000
|
timeout: 60_000,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -125,14 +125,14 @@ expect(text).toBe('Saved');
|
|||||||
|
|
||||||
### Anti-Patterns
|
### Anti-Patterns
|
||||||
|
|
||||||
| ❌ Don't | ✅ Do | Why |
|
| ❌ Don't | ✅ Do | Why |
|
||||||
| ------------------------------ | --------------------------------------------------- | ------------------------- |
|
|----------|-------|-----|
|
||||||
| `page.waitForTimeout(3000)` | `await expect(locator).toBeVisible()` | Hard waits are flaky |
|
| `page.waitForTimeout(3000)` | `await expect(locator).toBeVisible()` | Hard waits are flaky |
|
||||||
| `expect(await el.isVisible())` | `await expect(el).toBeVisible()` | No auto-retry |
|
| `expect(await el.isVisible())` | `await expect(el).toBeVisible()` | No auto-retry |
|
||||||
| `page.$('.btn')` | `page.getByRole('button')` | Fragile selector |
|
| `page.$('.btn')` | `page.getByRole('button')` | Fragile selector |
|
||||||
| `page.click('.submit')` | `page.getByRole('button', {name:'Submit'}).click()` | Not accessible |
|
| `page.click('.submit')` | `page.getByRole('button', {name:'Submit'}).click()` | Not accessible |
|
||||||
| Shared state between tests | `test.beforeEach` for setup | Tests must be independent |
|
| Shared state between tests | `test.beforeEach` for setup | Tests must be independent |
|
||||||
| `try/catch` around assertions | Let Playwright handle retries | Swallows real failures |
|
| `try/catch` around assertions | Let Playwright handle retries | Swallows real failures |
|
||||||
|
|
||||||
### Test Structure
|
### Test Structure
|
||||||
|
|
||||||
@@ -191,14 +191,14 @@ export class LoginPage {
|
|||||||
|
|
||||||
**Key pages to model (match `app.routes.ts`):**
|
**Key pages to model (match `app.routes.ts`):**
|
||||||
|
|
||||||
| Route | Page Object | Component |
|
| Route | Page Object | Component |
|
||||||
| ------------------- | ------------------ | ----------------------- |
|
|-------|-------------|-----------|
|
||||||
| `/login` | `LoginPage` | `LoginComponent` |
|
| `/login` | `LoginPage` | `LoginComponent` |
|
||||||
| `/register` | `RegisterPage` | `RegisterComponent` |
|
| `/register` | `RegisterPage` | `RegisterComponent` |
|
||||||
| `/search` | `ServerSearchPage` | `ServerSearchComponent` |
|
| `/search` | `ServerSearchPage` | `ServerSearchComponent` |
|
||||||
| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` |
|
| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` |
|
||||||
| `/settings` | `SettingsPage` | `SettingsComponent` |
|
| `/settings` | `SettingsPage` | `SettingsComponent` |
|
||||||
| `/invite/:inviteId` | `InvitePage` | `InviteComponent` |
|
| `/invite/:inviteId` | `InvitePage` | `InviteComponent` |
|
||||||
|
|
||||||
## Step 5 — MetoYou App Architecture Context
|
## Step 5 — MetoYou App Architecture Context
|
||||||
|
|
||||||
@@ -206,35 +206,35 @@ The agent writing tests MUST understand these domain boundaries:
|
|||||||
|
|
||||||
### Voice/WebRTC Stack
|
### Voice/WebRTC Stack
|
||||||
|
|
||||||
| Layer | What It Does | Test Relevance |
|
| Layer | What It Does | Test Relevance |
|
||||||
| ----------------------- | ----------------------------------------------------- | -------------------------------- |
|
|-------|-------------|----------------|
|
||||||
| `VoiceConnectionFacade` | High-level voice API (connect/disconnect/mute/deafen) | State signals to assert against |
|
| `VoiceConnectionFacade` | High-level voice API (connect/disconnect/mute/deafen) | State signals to assert against |
|
||||||
| `VoiceSessionFacade` | Session lifecycle, workspace layout | UI mode changes |
|
| `VoiceSessionFacade` | Session lifecycle, workspace layout | UI mode changes |
|
||||||
| `VoiceActivityService` | Speaking detection (RMS threshold 0.015) | `isSpeaking()` signal validation |
|
| `VoiceActivityService` | Speaking detection (RMS threshold 0.015) | `isSpeaking()` signal validation |
|
||||||
| `VoicePlaybackService` | Per-peer GainNode (0–200% volume) | Volume level assertions |
|
| `VoicePlaybackService` | Per-peer GainNode (0–200% volume) | Volume level assertions |
|
||||||
| `PeerConnectionManager` | RTCPeerConnection lifecycle | Connection state introspection |
|
| `PeerConnectionManager` | RTCPeerConnection lifecycle | Connection state introspection |
|
||||||
| `MediaManager` | getUserMedia, mute, gain chain | Track state validation |
|
| `MediaManager` | getUserMedia, mute, gain chain | Track state validation |
|
||||||
| `SignalingManager` | WebSocket per signal URL | Connection establishment |
|
| `SignalingManager` | WebSocket per signal URL | Connection establishment |
|
||||||
|
|
||||||
### Voice UI Components
|
### Voice UI Components
|
||||||
|
|
||||||
| Component | Selector | Contains |
|
| Component | Selector | Contains |
|
||||||
| ----------------------------------- | --------------------------------- | ------------------------------------------- |
|
|-----------|----------|----------|
|
||||||
| `VoiceWorkspaceComponent` | `app-voice-workspace` | Stream tiles, layout |
|
| `VoiceWorkspaceComponent` | `app-voice-workspace` | Stream tiles, layout |
|
||||||
| `VoiceControlsComponent` | `app-voice-controls` | Mute, camera, screen share, hang-up buttons |
|
| `VoiceControlsComponent` | `app-voice-controls` | Mute, camera, screen share, hang-up buttons |
|
||||||
| `FloatingVoiceControlsComponent` | `app-floating-voice-controls` | Floating variant of controls |
|
| `FloatingVoiceControlsComponent` | `app-floating-voice-controls` | Floating variant of controls |
|
||||||
| `VoiceWorkspaceStreamTileComponent` | `app-voice-workspace-stream-tile` | Per-peer audio/video tile |
|
| `VoiceWorkspaceStreamTileComponent` | `app-voice-workspace-stream-tile` | Per-peer audio/video tile |
|
||||||
|
|
||||||
### Voice UI Icons (Lucide)
|
### Voice UI Icons (Lucide)
|
||||||
|
|
||||||
| Icon | Meaning |
|
| Icon | Meaning |
|
||||||
| ------------------------------------ | -------------------- |
|
|------|---------|
|
||||||
| `lucideMic` / `lucideMicOff` | Mute toggle |
|
| `lucideMic` / `lucideMicOff` | Mute toggle |
|
||||||
| `lucideVideo` / `lucideVideoOff` | Camera toggle |
|
| `lucideVideo` / `lucideVideoOff` | Camera toggle |
|
||||||
| `lucideMonitor` / `lucideMonitorOff` | Screen share toggle |
|
| `lucideMonitor` / `lucideMonitorOff` | Screen share toggle |
|
||||||
| `lucidePhoneOff` | Hang up / disconnect |
|
| `lucidePhoneOff` | Hang up / disconnect |
|
||||||
| `lucideHeadphones` | Deafen state |
|
| `lucideHeadphones` | Deafen state |
|
||||||
| `lucideVolume2` / `lucideVolumeX` | Volume indicator |
|
| `lucideVolume2` / `lucideVolumeX` | Volume indicator |
|
||||||
|
|
||||||
### Server & Signaling
|
### Server & Signaling
|
||||||
|
|
||||||
@@ -256,7 +256,6 @@ After generating any test:
|
|||||||
```
|
```
|
||||||
|
|
||||||
If the test involves WebRTC, always verify:
|
If the test involves WebRTC, always verify:
|
||||||
|
|
||||||
- Fake media flags are set in config
|
- Fake media flags are set in config
|
||||||
- Timeouts are sufficient (60s+ for connection establishment)
|
- Timeouts are sufficient (60s+ for connection establishment)
|
||||||
- `workers: 1` if tests share server state
|
- `workers: 1` if tests share server state
|
||||||
@@ -277,7 +276,7 @@ npx playwright codegen http://localhost:4200 # Record test
|
|||||||
|
|
||||||
## Reference Files
|
## 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/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 |
|
||||||
|
|||||||
@@ -17,13 +17,6 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Restore npm cache
|
|
||||||
uses: https://github.com/actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/AppData/Local/npm-cache
|
|
||||||
key: npm-windows-${{ hashFiles('package-lock.json', 'website/package-lock.json') }}
|
|
||||||
restore-keys: npm-windows-
|
|
||||||
|
|
||||||
- name: Install root dependencies
|
- name: Install root dependencies
|
||||||
env:
|
env:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
|||||||
@@ -48,30 +48,18 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Restore npm cache
|
|
||||||
uses: https://github.com/actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: /root/.npm
|
|
||||||
key: npm-linux-${{ hashFiles('package-lock.json', 'server/package-lock.json') }}
|
|
||||||
restore-keys: npm-linux-
|
|
||||||
|
|
||||||
- name: Restore Electron cache
|
|
||||||
uses: https://github.com/actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
/root/.cache/electron
|
|
||||||
/root/.cache/electron-builder
|
|
||||||
key: electron-linux-${{ hashFiles('package.json') }}
|
|
||||||
restore-keys: electron-linux-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
run: |
|
run: |
|
||||||
apt-get update && apt-get install -y --no-install-recommends zip
|
|
||||||
npm ci
|
npm ci
|
||||||
cd server && npm ci
|
cd server && npm ci
|
||||||
|
|
||||||
|
- name: Install zip utility
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y zip
|
||||||
|
|
||||||
- name: Set CI release version
|
- name: Set CI release version
|
||||||
run: >
|
run: >
|
||||||
node tools/set-release-version.js
|
node tools/set-release-version.js
|
||||||
@@ -120,22 +108,6 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Restore npm cache
|
|
||||||
uses: https://github.com/actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/AppData/Local/npm-cache
|
|
||||||
key: npm-windows-${{ hashFiles('package-lock.json', 'server/package-lock.json') }}
|
|
||||||
restore-keys: npm-windows-
|
|
||||||
|
|
||||||
- name: Restore Electron cache
|
|
||||||
uses: https://github.com/actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/AppData/Local/electron/Cache
|
|
||||||
~/AppData/Local/electron-builder/Cache
|
|
||||||
key: electron-windows-${{ hashFiles('package.json') }}
|
|
||||||
restore-keys: electron-windows-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
@@ -245,6 +217,9 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --omit=dev
|
||||||
|
|
||||||
- name: Download previous manifest
|
- name: Download previous manifest
|
||||||
env:
|
env:
|
||||||
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
|||||||
@@ -5,62 +5,40 @@ import {
|
|||||||
type BrowserContext,
|
type BrowserContext,
|
||||||
type Browser
|
type Browser
|
||||||
} from '@playwright/test';
|
} from '@playwright/test';
|
||||||
import { spawn, type ChildProcess } from 'node:child_process';
|
|
||||||
import { once } from 'node:events';
|
|
||||||
import { createServer } from 'node:net';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { installTestServerEndpoint } from '../helpers/seed-test-endpoint';
|
import { installTestServerEndpoint } from '../helpers/seed-test-endpoint';
|
||||||
|
|
||||||
export interface Client {
|
export type Client = {
|
||||||
page: Page;
|
page: Page;
|
||||||
context: BrowserContext;
|
context: BrowserContext;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface TestServerHandle {
|
type MultiClientFixture = {
|
||||||
port: number;
|
|
||||||
url: string;
|
|
||||||
stop: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MultiClientFixture {
|
|
||||||
createClient: () => Promise<Client>;
|
createClient: () => Promise<Client>;
|
||||||
testServer: TestServerHandle;
|
browser: Browser;
|
||||||
}
|
};
|
||||||
|
|
||||||
const FAKE_AUDIO_FILE = join(__dirname, 'test-tone.wav');
|
|
||||||
const CHROMIUM_FAKE_MEDIA_ARGS = [
|
|
||||||
'--use-fake-device-for-media-stream',
|
|
||||||
'--use-fake-ui-for-media-stream',
|
|
||||||
`--use-file-for-fake-audio-capture=${FAKE_AUDIO_FILE}`
|
|
||||||
];
|
|
||||||
const E2E_DIR = join(__dirname, '..');
|
|
||||||
const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
|
|
||||||
|
|
||||||
export const test = base.extend<MultiClientFixture>({
|
export const test = base.extend<MultiClientFixture>({
|
||||||
testServer: async ({ playwright: _playwright }, use: (testServer: TestServerHandle) => Promise<void>) => {
|
browser: async ({}, use) => {
|
||||||
const testServer = await startTestServer();
|
const browser = await chromium.launch({
|
||||||
|
args: [
|
||||||
await use(testServer);
|
'--use-fake-device-for-media-stream',
|
||||||
await testServer.stop();
|
'--use-fake-ui-for-media-stream',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await use(browser);
|
||||||
|
await browser.close();
|
||||||
},
|
},
|
||||||
|
|
||||||
createClient: async ({ testServer }, use) => {
|
createClient: async ({ browser }, use) => {
|
||||||
const browsers: Browser[] = [];
|
|
||||||
const clients: Client[] = [];
|
const clients: Client[] = [];
|
||||||
|
|
||||||
const factory = async (): Promise<Client> => {
|
const factory = async (): Promise<Client> => {
|
||||||
// Launch a dedicated browser per client so each gets its own fake
|
|
||||||
// audio device - shared browsers can starve the first context's
|
|
||||||
// audio capture under load.
|
|
||||||
const browser = await chromium.launch({ args: CHROMIUM_FAKE_MEDIA_ARGS });
|
|
||||||
|
|
||||||
browsers.push(browser);
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
permissions: ['microphone', 'camera'],
|
permissions: ['microphone', 'camera'],
|
||||||
baseURL: 'http://localhost:4200'
|
baseURL: 'http://localhost:4200'
|
||||||
});
|
});
|
||||||
|
|
||||||
await installTestServerEndpoint(context, testServer.port);
|
await installTestServerEndpoint(context);
|
||||||
|
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
@@ -73,130 +51,7 @@ export const test = base.extend<MultiClientFixture>({
|
|||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await client.context.close();
|
await client.context.close();
|
||||||
}
|
}
|
||||||
|
},
|
||||||
for (const browser of browsers) {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { expect } from '@playwright/test';
|
export { expect } from '@playwright/test';
|
||||||
|
|
||||||
async function startTestServer(retries = 3): Promise<TestServerHandle> {
|
|
||||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
||||||
const port = await allocatePort();
|
|
||||||
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
|
|
||||||
cwd: E2E_DIR,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TEST_SERVER_PORT: String(port)
|
|
||||||
},
|
|
||||||
stdio: 'pipe'
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stdout?.on('data', (chunk: Buffer | string) => {
|
|
||||||
process.stdout.write(chunk.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr?.on('data', (chunk: Buffer | string) => {
|
|
||||||
process.stderr.write(chunk.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForServerReady(port, child);
|
|
||||||
} catch (error) {
|
|
||||||
await stopServer(child);
|
|
||||||
|
|
||||||
if (attempt < retries) {
|
|
||||||
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
port,
|
|
||||||
url: `http://localhost:${port}`,
|
|
||||||
stop: async () => {
|
|
||||||
await stopServer(child);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('startTestServer: unreachable');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function allocatePort(): Promise<number> {
|
|
||||||
return new Promise<number>((resolve, reject) => {
|
|
||||||
const probe = createServer();
|
|
||||||
|
|
||||||
probe.once('error', reject);
|
|
||||||
probe.listen(0, '127.0.0.1', () => {
|
|
||||||
const address = probe.address();
|
|
||||||
|
|
||||||
if (!address || typeof address === 'string') {
|
|
||||||
probe.close();
|
|
||||||
reject(new Error('Failed to resolve an ephemeral test server port'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { port } = address;
|
|
||||||
|
|
||||||
probe.close((error) => {
|
|
||||||
if (error) {
|
|
||||||
reject(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForServerReady(port: number, child: ChildProcess, timeoutMs = 30_000): Promise<void> {
|
|
||||||
const readyUrl = `http://127.0.0.1:${port}/api/servers?limit=1`;
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (child.exitCode !== null) {
|
|
||||||
throw new Error(`Test server exited before becoming ready (exit code ${child.exitCode})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(readyUrl);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Server still starting.
|
|
||||||
}
|
|
||||||
|
|
||||||
await wait(250);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Timed out waiting for test server on port ${port}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopServer(child: ChildProcess): Promise<void> {
|
|
||||||
if (child.exitCode !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
child.kill('SIGTERM');
|
|
||||||
|
|
||||||
const exited = await Promise.race([once(child, 'exit').then(() => true), wait(3_000).then(() => false)]);
|
|
||||||
|
|
||||||
if (!exited && child.exitCode === null) {
|
|
||||||
child.kill('SIGKILL');
|
|
||||||
await once(child, 'exit');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function wait(durationMs: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, durationMs);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
Binary file not shown.
@@ -59,8 +59,6 @@ const child = spawn(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let shuttingDown = false;
|
|
||||||
|
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
console.error('[E2E Server] Failed to start:', err.message);
|
console.error('[E2E Server] Failed to start:', err.message);
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -70,10 +68,6 @@ child.on('error', (err) => {
|
|||||||
child.on('exit', (code) => {
|
child.on('exit', (code) => {
|
||||||
console.log(`[E2E Server] Exited with code ${code}`);
|
console.log(`[E2E Server] Exited with code ${code}`);
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
||||||
if (shuttingDown) {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Cleanup on signals ───────────────────────────────────────────────
|
// ── Cleanup on signals ───────────────────────────────────────────────
|
||||||
@@ -87,18 +81,12 @@ function cleanup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
if (shuttingDown) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shuttingDown = true;
|
|
||||||
child.kill('SIGTERM');
|
child.kill('SIGTERM');
|
||||||
|
|
||||||
// Give child 3s to exit, then force kill
|
// Give child 3s to exit, then force kill
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (child.exitCode === null) {
|
if (!child.killed) child.kill('SIGKILL');
|
||||||
child.kill('SIGKILL');
|
cleanup();
|
||||||
}
|
process.exit(0);
|
||||||
}, 3_000);
|
}, 3_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,104 +39,6 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
|||||||
|
|
||||||
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
||||||
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
|
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
|
||||||
|
|
||||||
// Patch getUserMedia to use an AudioContext oscillator for audio
|
|
||||||
// instead of the hardware capture device. Chromium's fake audio
|
|
||||||
// device intermittently fails to produce frames after renegotiation.
|
|
||||||
const origGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
|
||||||
|
|
||||||
navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => {
|
|
||||||
const wantsAudio = !!constraints?.audio;
|
|
||||||
|
|
||||||
if (!wantsAudio) {
|
|
||||||
return origGetUserMedia(constraints);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the original stream (may include video)
|
|
||||||
const originalStream = await origGetUserMedia(constraints);
|
|
||||||
const audioCtx = new AudioContext();
|
|
||||||
const oscillator = audioCtx.createOscillator();
|
|
||||||
|
|
||||||
oscillator.frequency.value = 440;
|
|
||||||
|
|
||||||
const dest = audioCtx.createMediaStreamDestination();
|
|
||||||
|
|
||||||
oscillator.connect(dest);
|
|
||||||
oscillator.start();
|
|
||||||
|
|
||||||
const synthAudioTrack = dest.stream.getAudioTracks()[0];
|
|
||||||
const resultStream = new MediaStream();
|
|
||||||
|
|
||||||
resultStream.addTrack(synthAudioTrack);
|
|
||||||
|
|
||||||
// Keep any video tracks from the original stream
|
|
||||||
for (const videoTrack of originalStream.getVideoTracks()) {
|
|
||||||
resultStream.addTrack(videoTrack);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop original audio tracks since we're not using them
|
|
||||||
for (const track of originalStream.getAudioTracks()) {
|
|
||||||
track.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultStream;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Patch getDisplayMedia to return a synthetic screen share stream
|
|
||||||
// (canvas-based video + 880Hz oscillator audio) so the browser
|
|
||||||
// picker dialog is never shown.
|
|
||||||
navigator.mediaDevices.getDisplayMedia = async (_constraints?: DisplayMediaStreamOptions) => {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
|
|
||||||
canvas.width = 640;
|
|
||||||
canvas.height = 480;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('Canvas 2D context unavailable');
|
|
||||||
}
|
|
||||||
|
|
||||||
let frameCount = 0;
|
|
||||||
|
|
||||||
// Draw animated frames so video stats show increasing bytes
|
|
||||||
const drawFrame = () => {
|
|
||||||
frameCount++;
|
|
||||||
ctx.fillStyle = `hsl(${frameCount % 360}, 70%, 50%)`;
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.font = '24px monospace';
|
|
||||||
ctx.fillText(`Screen Share Frame ${frameCount}`, 40, 60);
|
|
||||||
};
|
|
||||||
|
|
||||||
drawFrame();
|
|
||||||
const drawInterval = setInterval(drawFrame, 100);
|
|
||||||
const videoStream = canvas.captureStream(10); // 10 fps
|
|
||||||
const videoTrack = videoStream.getVideoTracks()[0];
|
|
||||||
|
|
||||||
// Stop drawing when the track ends
|
|
||||||
videoTrack.addEventListener('ended', () => clearInterval(drawInterval));
|
|
||||||
|
|
||||||
// Create 880Hz oscillator for screen share audio (distinct from 440Hz voice)
|
|
||||||
const audioCtx = new AudioContext();
|
|
||||||
const osc = audioCtx.createOscillator();
|
|
||||||
|
|
||||||
osc.frequency.value = 880;
|
|
||||||
|
|
||||||
const dest = audioCtx.createMediaStreamDestination();
|
|
||||||
|
|
||||||
osc.connect(dest);
|
|
||||||
osc.start();
|
|
||||||
|
|
||||||
const audioTrack = dest.stream.getAudioTracks()[0];
|
|
||||||
// Combine video + audio into one stream
|
|
||||||
const resultStream = new MediaStream([videoTrack, audioTrack]);
|
|
||||||
|
|
||||||
// Tag the stream so tests can identify it
|
|
||||||
(resultStream as any).__isScreenShare = true;
|
|
||||||
|
|
||||||
return resultStream;
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,10 +66,7 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get outbound and inbound audio RTP stats aggregated across all peer
|
* Get outbound and inbound audio RTP stats from the first peer connection.
|
||||||
* connections. Uses a per-connection high water mark stored on `window` so
|
|
||||||
* that connections that close mid-measurement still contribute their last
|
|
||||||
* known counters, preventing the aggregate from going backwards.
|
|
||||||
*/
|
*/
|
||||||
export async function getAudioStats(page: Page): Promise<{
|
export async function getAudioStats(page: Page): Promise<{
|
||||||
outbound: { bytesSent: number; packetsSent: number } | null;
|
outbound: { bytesSent: number; packetsSent: number } | null;
|
||||||
@@ -179,90 +78,38 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return { outbound: null, inbound: null };
|
return { outbound: null, inbound: null };
|
||||||
|
|
||||||
interface HWMEntry {
|
let outbound: { bytesSent: number; packetsSent: number } | null = null;
|
||||||
outBytesSent: number;
|
let inbound: { bytesReceived: number; packetsReceived: number } | null = null;
|
||||||
outPacketsSent: number;
|
|
||||||
inBytesReceived: number;
|
|
||||||
inPacketsReceived: number;
|
|
||||||
hasOutbound: boolean;
|
|
||||||
hasInbound: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hwm: Record<number, HWMEntry> = (window as any).__rtcStatsHWM =
|
for (const pc of connections) {
|
||||||
((window as any).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
if (pc.connectionState !== 'connected')
|
||||||
|
continue;
|
||||||
|
|
||||||
for (let idx = 0; idx < connections.length; idx++) {
|
const stats = await pc.getStats();
|
||||||
let stats: RTCStatsReport;
|
|
||||||
|
|
||||||
try {
|
|
||||||
stats = await connections[idx].getStats();
|
|
||||||
} catch {
|
|
||||||
continue; // closed connection - keep its last HWM
|
|
||||||
}
|
|
||||||
|
|
||||||
let obytes = 0;
|
|
||||||
let opackets = 0;
|
|
||||||
let ibytes = 0;
|
|
||||||
let ipackets = 0;
|
|
||||||
let hasOut = false;
|
|
||||||
let hasIn = false;
|
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
stats.forEach((report: any) => {
|
||||||
const kind = report.kind ?? report.mediaType;
|
const reportMediaType = report.kind ?? report.mediaType;
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'audio') {
|
if (report.type === 'outbound-rtp' && reportMediaType === 'audio' && !outbound) {
|
||||||
hasOut = true;
|
outbound = {
|
||||||
obytes += report.bytesSent ?? 0;
|
bytesSent: report.bytesSent ?? 0,
|
||||||
opackets += report.packetsSent ?? 0;
|
packetsSent: report.packetsSent ?? 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (report.type === 'inbound-rtp' && kind === 'audio') {
|
if (report.type === 'inbound-rtp' && reportMediaType === 'audio' && !inbound) {
|
||||||
hasIn = true;
|
inbound = {
|
||||||
ibytes += report.bytesReceived ?? 0;
|
bytesReceived: report.bytesReceived ?? 0,
|
||||||
ipackets += report.packetsReceived ?? 0;
|
packetsReceived: report.packetsReceived ?? 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasOut || hasIn) {
|
if (outbound && inbound)
|
||||||
hwm[idx] = {
|
break;
|
||||||
outBytesSent: obytes,
|
|
||||||
outPacketsSent: opackets,
|
|
||||||
inBytesReceived: ibytes,
|
|
||||||
inPacketsReceived: ipackets,
|
|
||||||
hasOutbound: hasOut,
|
|
||||||
hasInbound: hasIn
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalOutBytes = 0;
|
return { outbound, inbound };
|
||||||
let totalOutPackets = 0;
|
|
||||||
let totalInBytes = 0;
|
|
||||||
let totalInPackets = 0;
|
|
||||||
let anyOutbound = false;
|
|
||||||
let anyInbound = false;
|
|
||||||
|
|
||||||
for (const entry of Object.values(hwm)) {
|
|
||||||
totalOutBytes += entry.outBytesSent;
|
|
||||||
totalOutPackets += entry.outPacketsSent;
|
|
||||||
totalInBytes += entry.inBytesReceived;
|
|
||||||
totalInPackets += entry.inPacketsReceived;
|
|
||||||
|
|
||||||
if (entry.hasOutbound)
|
|
||||||
anyOutbound = true;
|
|
||||||
|
|
||||||
if (entry.hasInbound)
|
|
||||||
anyInbound = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
outbound: anyOutbound
|
|
||||||
? { bytesSent: totalOutBytes, packetsSent: totalOutPackets }
|
|
||||||
: null,
|
|
||||||
inbound: anyInbound
|
|
||||||
? { bytesReceived: totalInBytes, packetsReceived: totalInPackets }
|
|
||||||
: null
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,8 +120,6 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promise<{
|
export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promise<{
|
||||||
outboundBytesDelta: number;
|
outboundBytesDelta: number;
|
||||||
inboundBytesDelta: number;
|
inboundBytesDelta: number;
|
||||||
outboundPacketsDelta: number;
|
|
||||||
inboundPacketsDelta: number;
|
|
||||||
}> {
|
}> {
|
||||||
const before = await getAudioStats(page);
|
const before = await getAudioStats(page);
|
||||||
|
|
||||||
@@ -284,434 +129,6 @@ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promis
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
outboundBytesDelta: (after.outbound?.bytesSent ?? 0) - (before.outbound?.bytesSent ?? 0),
|
outboundBytesDelta: (after.outbound?.bytesSent ?? 0) - (before.outbound?.bytesSent ?? 0),
|
||||||
inboundBytesDelta: (after.inbound?.bytesReceived ?? 0) - (before.inbound?.bytesReceived ?? 0),
|
inboundBytesDelta: (after.inbound?.bytesReceived ?? 0) - (before.inbound?.bytesReceived ?? 0)
|
||||||
outboundPacketsDelta: (after.outbound?.packetsSent ?? 0) - (before.outbound?.packetsSent ?? 0),
|
|
||||||
inboundPacketsDelta: (after.inbound?.packetsReceived ?? 0) - (before.inbound?.packetsReceived ?? 0)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait until at least one connection has both outbound-rtp and inbound-rtp
|
|
||||||
* audio reports. Call after `waitForPeerConnected` to ensure the audio
|
|
||||||
* pipeline is ready before measuring deltas.
|
|
||||||
*/
|
|
||||||
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
async () => {
|
|
||||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
|
||||||
|
|
||||||
if (!connections?.length)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
for (const pc of connections) {
|
|
||||||
let stats: RTCStatsReport;
|
|
||||||
|
|
||||||
try {
|
|
||||||
stats = await pc.getStats();
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasOut = false;
|
|
||||||
let hasIn = false;
|
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
|
||||||
const kind = report.kind ?? report.mediaType;
|
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'audio')
|
|
||||||
hasOut = true;
|
|
||||||
|
|
||||||
if (report.type === 'inbound-rtp' && kind === 'audio')
|
|
||||||
hasIn = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasOut && hasIn)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
{ timeout }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AudioFlowDelta {
|
|
||||||
outboundBytesDelta: number;
|
|
||||||
inboundBytesDelta: number;
|
|
||||||
outboundPacketsDelta: number;
|
|
||||||
inboundPacketsDelta: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotToDelta(
|
|
||||||
curr: Awaited<ReturnType<typeof getAudioStats>>,
|
|
||||||
prev: Awaited<ReturnType<typeof getAudioStats>>
|
|
||||||
): AudioFlowDelta {
|
|
||||||
return {
|
|
||||||
outboundBytesDelta: (curr.outbound?.bytesSent ?? 0) - (prev.outbound?.bytesSent ?? 0),
|
|
||||||
inboundBytesDelta: (curr.inbound?.bytesReceived ?? 0) - (prev.inbound?.bytesReceived ?? 0),
|
|
||||||
outboundPacketsDelta: (curr.outbound?.packetsSent ?? 0) - (prev.outbound?.packetsSent ?? 0),
|
|
||||||
inboundPacketsDelta: (curr.inbound?.packetsReceived ?? 0) - (prev.inbound?.packetsReceived ?? 0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDeltaFlowing(delta: AudioFlowDelta): boolean {
|
|
||||||
const outFlowing = delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0;
|
|
||||||
const inFlowing = delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0;
|
|
||||||
|
|
||||||
return outFlowing && inFlowing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Poll until two consecutive HWM-based reads show both outbound and inbound
|
|
||||||
* audio byte counts increasing. Combines per-connection high-water marks
|
|
||||||
* (which prevent totals from going backwards after connection churn) with
|
|
||||||
* consecutive comparison (which avoids a stale single baseline).
|
|
||||||
*/
|
|
||||||
export async function waitForAudioFlow(
|
|
||||||
page: Page,
|
|
||||||
timeoutMs = 30_000,
|
|
||||||
pollIntervalMs = 1_000
|
|
||||||
): Promise<AudioFlowDelta> {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
|
|
||||||
let prev = await getAudioStats(page);
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
await page.waitForTimeout(pollIntervalMs);
|
|
||||||
const curr = await getAudioStats(page);
|
|
||||||
const delta = snapshotToDelta(curr, prev);
|
|
||||||
|
|
||||||
if (isDeltaFlowing(delta)) {
|
|
||||||
return delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
prev = curr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout - return zero deltas so the caller's assertion reports the failure.
|
|
||||||
return {
|
|
||||||
outboundBytesDelta: 0,
|
|
||||||
inboundBytesDelta: 0,
|
|
||||||
outboundPacketsDelta: 0,
|
|
||||||
inboundPacketsDelta: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get outbound and inbound video RTP stats aggregated across all peer
|
|
||||||
* connections. Uses the same HWM pattern as {@link getAudioStats}.
|
|
||||||
*/
|
|
||||||
export async function getVideoStats(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 };
|
|
||||||
|
|
||||||
interface VHWM {
|
|
||||||
outBytesSent: number;
|
|
||||||
outPacketsSent: number;
|
|
||||||
inBytesReceived: number;
|
|
||||||
inPacketsReceived: number;
|
|
||||||
hasOutbound: boolean;
|
|
||||||
hasInbound: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hwm: Record<number, VHWM> = (window as any).__rtcVideoStatsHWM =
|
|
||||||
((window as any).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
|
||||||
|
|
||||||
for (let idx = 0; idx < connections.length; idx++) {
|
|
||||||
let stats: RTCStatsReport;
|
|
||||||
|
|
||||||
try {
|
|
||||||
stats = await connections[idx].getStats();
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let obytes = 0;
|
|
||||||
let opackets = 0;
|
|
||||||
let ibytes = 0;
|
|
||||||
let ipackets = 0;
|
|
||||||
let hasOut = false;
|
|
||||||
let hasIn = false;
|
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
|
||||||
const kind = report.kind ?? report.mediaType;
|
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'video') {
|
|
||||||
hasOut = true;
|
|
||||||
obytes += report.bytesSent ?? 0;
|
|
||||||
opackets += report.packetsSent ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (report.type === 'inbound-rtp' && kind === 'video') {
|
|
||||||
hasIn = true;
|
|
||||||
ibytes += report.bytesReceived ?? 0;
|
|
||||||
ipackets += report.packetsReceived ?? 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasOut || hasIn) {
|
|
||||||
hwm[idx] = {
|
|
||||||
outBytesSent: obytes,
|
|
||||||
outPacketsSent: opackets,
|
|
||||||
inBytesReceived: ibytes,
|
|
||||||
inPacketsReceived: ipackets,
|
|
||||||
hasOutbound: hasOut,
|
|
||||||
hasInbound: hasIn
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalOutBytes = 0;
|
|
||||||
let totalOutPackets = 0;
|
|
||||||
let totalInBytes = 0;
|
|
||||||
let totalInPackets = 0;
|
|
||||||
let anyOutbound = false;
|
|
||||||
let anyInbound = false;
|
|
||||||
|
|
||||||
for (const entry of Object.values(hwm)) {
|
|
||||||
totalOutBytes += entry.outBytesSent;
|
|
||||||
totalOutPackets += entry.outPacketsSent;
|
|
||||||
totalInBytes += entry.inBytesReceived;
|
|
||||||
totalInPackets += entry.inPacketsReceived;
|
|
||||||
|
|
||||||
if (entry.hasOutbound)
|
|
||||||
anyOutbound = true;
|
|
||||||
|
|
||||||
if (entry.hasInbound)
|
|
||||||
anyInbound = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
outbound: anyOutbound
|
|
||||||
? { bytesSent: totalOutBytes, packetsSent: totalOutPackets }
|
|
||||||
: null,
|
|
||||||
inbound: anyInbound
|
|
||||||
? { bytesReceived: totalInBytes, packetsReceived: totalInPackets }
|
|
||||||
: null
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait until at least one connection has both outbound-rtp and inbound-rtp
|
|
||||||
* video reports.
|
|
||||||
*/
|
|
||||||
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
async () => {
|
|
||||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
|
||||||
|
|
||||||
if (!connections?.length)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
for (const pc of connections) {
|
|
||||||
let stats: RTCStatsReport;
|
|
||||||
|
|
||||||
try {
|
|
||||||
stats = await pc.getStats();
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasOut = false;
|
|
||||||
let hasIn = false;
|
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
|
||||||
const kind = report.kind ?? report.mediaType;
|
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'video')
|
|
||||||
hasOut = true;
|
|
||||||
|
|
||||||
if (report.type === 'inbound-rtp' && kind === 'video')
|
|
||||||
hasIn = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasOut && hasIn)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
{ timeout }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VideoFlowDelta {
|
|
||||||
outboundBytesDelta: number;
|
|
||||||
inboundBytesDelta: number;
|
|
||||||
outboundPacketsDelta: number;
|
|
||||||
inboundPacketsDelta: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function videoSnapshotToDelta(
|
|
||||||
curr: Awaited<ReturnType<typeof getVideoStats>>,
|
|
||||||
prev: Awaited<ReturnType<typeof getVideoStats>>
|
|
||||||
): VideoFlowDelta {
|
|
||||||
return {
|
|
||||||
outboundBytesDelta: (curr.outbound?.bytesSent ?? 0) - (prev.outbound?.bytesSent ?? 0),
|
|
||||||
inboundBytesDelta: (curr.inbound?.bytesReceived ?? 0) - (prev.inbound?.bytesReceived ?? 0),
|
|
||||||
outboundPacketsDelta: (curr.outbound?.packetsSent ?? 0) - (prev.outbound?.packetsSent ?? 0),
|
|
||||||
inboundPacketsDelta: (curr.inbound?.packetsReceived ?? 0) - (prev.inbound?.packetsReceived ?? 0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVideoDeltaFlowing(delta: VideoFlowDelta): boolean {
|
|
||||||
const outFlowing = delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0;
|
|
||||||
const inFlowing = delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0;
|
|
||||||
|
|
||||||
return outFlowing && inFlowing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Poll until two consecutive HWM-based reads show both outbound and inbound
|
|
||||||
* video byte counts increasing - proving screen share video is flowing.
|
|
||||||
*/
|
|
||||||
export async function waitForVideoFlow(
|
|
||||||
page: Page,
|
|
||||||
timeoutMs = 30_000,
|
|
||||||
pollIntervalMs = 1_000
|
|
||||||
): Promise<VideoFlowDelta> {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
|
|
||||||
let prev = await getVideoStats(page);
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
await page.waitForTimeout(pollIntervalMs);
|
|
||||||
const curr = await getVideoStats(page);
|
|
||||||
const delta = videoSnapshotToDelta(curr, prev);
|
|
||||||
|
|
||||||
if (isVideoDeltaFlowing(delta)) {
|
|
||||||
return delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
prev = curr;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
outboundBytesDelta: 0,
|
|
||||||
inboundBytesDelta: 0,
|
|
||||||
outboundPacketsDelta: 0,
|
|
||||||
inboundPacketsDelta: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait until outbound video bytes are increasing (sender side).
|
|
||||||
* Use on the page that is sharing its screen.
|
|
||||||
*/
|
|
||||||
export async function waitForOutboundVideoFlow(
|
|
||||||
page: Page,
|
|
||||||
timeoutMs = 30_000,
|
|
||||||
pollIntervalMs = 1_000
|
|
||||||
): Promise<VideoFlowDelta> {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
|
|
||||||
let prev = await getVideoStats(page);
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
await page.waitForTimeout(pollIntervalMs);
|
|
||||||
const curr = await getVideoStats(page);
|
|
||||||
const delta = videoSnapshotToDelta(curr, prev);
|
|
||||||
|
|
||||||
if (delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0) {
|
|
||||||
return delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
prev = curr;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
outboundBytesDelta: 0,
|
|
||||||
inboundBytesDelta: 0,
|
|
||||||
outboundPacketsDelta: 0,
|
|
||||||
inboundPacketsDelta: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait until inbound video bytes are increasing (receiver side).
|
|
||||||
* Use on the page that is viewing someone else's screen share.
|
|
||||||
*/
|
|
||||||
export async function waitForInboundVideoFlow(
|
|
||||||
page: Page,
|
|
||||||
timeoutMs = 30_000,
|
|
||||||
pollIntervalMs = 1_000
|
|
||||||
): Promise<VideoFlowDelta> {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
|
|
||||||
let prev = await getVideoStats(page);
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
await page.waitForTimeout(pollIntervalMs);
|
|
||||||
const curr = await getVideoStats(page);
|
|
||||||
const delta = videoSnapshotToDelta(curr, prev);
|
|
||||||
|
|
||||||
if (delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0) {
|
|
||||||
return delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
prev = curr;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
outboundBytesDelta: 0,
|
|
||||||
inboundBytesDelta: 0,
|
|
||||||
outboundPacketsDelta: 0,
|
|
||||||
inboundPacketsDelta: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dump full RTC connection diagnostics for debugging audio flow failures.
|
|
||||||
*/
|
|
||||||
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
|
||||||
return page.evaluate(async () => {
|
|
||||||
const conns = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
|
||||||
|
|
||||||
if (!conns?.length)
|
|
||||||
return 'No connections tracked';
|
|
||||||
|
|
||||||
const lines: string[] = [`Total connections: ${conns.length}`];
|
|
||||||
|
|
||||||
for (let idx = 0; idx < conns.length; idx++) {
|
|
||||||
const pc = conns[idx];
|
|
||||||
|
|
||||||
lines.push(`PC[${idx}]: connection=${pc.connectionState}, signaling=${pc.signalingState}`);
|
|
||||||
|
|
||||||
const senders = pc.getSenders().map(
|
|
||||||
(sender) => `${sender.track?.kind ?? 'none'}:enabled=${sender.track?.enabled}:${sender.track?.readyState ?? 'null'}`
|
|
||||||
);
|
|
||||||
const receivers = pc.getReceivers().map(
|
|
||||||
(recv) => `${recv.track?.kind ?? 'none'}:enabled=${recv.track?.enabled}:${recv.track?.readyState ?? 'null'}`
|
|
||||||
);
|
|
||||||
|
|
||||||
lines.push(` senders=[${senders.join(', ')}]`);
|
|
||||||
lines.push(` receivers=[${receivers.join(', ')}]`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stats = await pc.getStats();
|
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
|
||||||
if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp')
|
|
||||||
return;
|
|
||||||
|
|
||||||
const kind = report.kind ?? report.mediaType;
|
|
||||||
const bytes = report.type === 'outbound-rtp' ? report.bytesSent : report.bytesReceived;
|
|
||||||
const packets = report.type === 'outbound-rtp' ? report.packetsSent : report.packetsReceived;
|
|
||||||
|
|
||||||
lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`);
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
lines.push(` getStats() failed: ${err?.message ?? err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import {
|
|
||||||
expect,
|
|
||||||
type Locator,
|
|
||||||
type Page
|
|
||||||
} from '@playwright/test';
|
|
||||||
|
|
||||||
export type ChatDropFilePayload = {
|
|
||||||
name: string;
|
|
||||||
mimeType: string;
|
|
||||||
base64: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ChatMessagesPage {
|
|
||||||
readonly composer: Locator;
|
|
||||||
readonly composerInput: Locator;
|
|
||||||
readonly sendButton: Locator;
|
|
||||||
readonly typingIndicator: Locator;
|
|
||||||
readonly gifButton: Locator;
|
|
||||||
readonly gifPicker: Locator;
|
|
||||||
readonly messageItems: Locator;
|
|
||||||
|
|
||||||
constructor(private page: Page) {
|
|
||||||
this.composer = page.locator('app-chat-message-composer');
|
|
||||||
this.composerInput = page.getByPlaceholder('Type a message...');
|
|
||||||
this.sendButton = page.getByRole('button', { name: 'Send message' });
|
|
||||||
this.typingIndicator = page.locator('app-typing-indicator');
|
|
||||||
this.gifButton = page.getByRole('button', { name: 'Search KLIPY GIFs' });
|
|
||||||
this.gifPicker = page.getByRole('dialog', { name: 'KLIPY GIF picker' });
|
|
||||||
this.messageItems = page.locator('[data-message-id]');
|
|
||||||
}
|
|
||||||
|
|
||||||
async waitForReady(): Promise<void> {
|
|
||||||
await expect(this.composerInput).toBeVisible({ timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendMessage(content: string): Promise<void> {
|
|
||||||
await this.waitForReady();
|
|
||||||
await this.composerInput.fill(content);
|
|
||||||
await this.sendButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async typeDraft(content: string): Promise<void> {
|
|
||||||
await this.waitForReady();
|
|
||||||
await this.composerInput.fill(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearDraft(): Promise<void> {
|
|
||||||
await this.waitForReady();
|
|
||||||
await this.composerInput.fill('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async attachFiles(files: ChatDropFilePayload[]): Promise<void> {
|
|
||||||
await this.waitForReady();
|
|
||||||
|
|
||||||
await this.composerInput.evaluate((element, payloads: ChatDropFilePayload[]) => {
|
|
||||||
const dataTransfer = new DataTransfer();
|
|
||||||
|
|
||||||
for (const payload of payloads) {
|
|
||||||
const binary = atob(payload.base64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
|
|
||||||
for (let index = 0; index < binary.length; index++) {
|
|
||||||
bytes[index] = binary.charCodeAt(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
dataTransfer.items.add(new File([bytes], payload.name, { type: payload.mimeType }));
|
|
||||||
}
|
|
||||||
|
|
||||||
element.dispatchEvent(new DragEvent('drop', {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
dataTransfer
|
|
||||||
}));
|
|
||||||
}, files);
|
|
||||||
}
|
|
||||||
|
|
||||||
async openGifPicker(): Promise<void> {
|
|
||||||
await this.waitForReady();
|
|
||||||
await this.gifButton.click();
|
|
||||||
await expect(this.gifPicker).toBeVisible({ timeout: 10_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async selectFirstGif(): Promise<void> {
|
|
||||||
const gifCard = this.gifPicker.getByRole('button', { name: /click to select/i }).first();
|
|
||||||
|
|
||||||
await expect(gifCard).toBeVisible({ timeout: 10_000 });
|
|
||||||
await gifCard.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
getMessageItemByText(text: string): Locator {
|
|
||||||
return this.messageItems.filter({
|
|
||||||
has: this.page.getByText(text, { exact: false })
|
|
||||||
}).last();
|
|
||||||
}
|
|
||||||
|
|
||||||
getMessageImageByAlt(altText: string): Locator {
|
|
||||||
return this.page.locator(`[data-message-id] img[alt="${altText}"]`).last();
|
|
||||||
}
|
|
||||||
|
|
||||||
async expectMessageImageLoaded(altText: string): Promise<void> {
|
|
||||||
const image = this.getMessageImageByAlt(altText);
|
|
||||||
|
|
||||||
await expect(image).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect.poll(async () =>
|
|
||||||
image.evaluate((element) => {
|
|
||||||
const img = element as HTMLImageElement;
|
|
||||||
|
|
||||||
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
|
|
||||||
}), {
|
|
||||||
timeout: 20_000,
|
|
||||||
message: `Image ${altText} should fully load in chat`
|
|
||||||
}).toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
getEmbedCardByTitle(title: string): Locator {
|
|
||||||
return this.page.locator('app-chat-link-embed').filter({
|
|
||||||
has: this.page.getByText(title, { exact: true })
|
|
||||||
}).last();
|
|
||||||
}
|
|
||||||
|
|
||||||
async editOwnMessage(originalText: string, updatedText: string): Promise<void> {
|
|
||||||
const messageItem = this.getMessageItemByText(originalText);
|
|
||||||
const editButton = messageItem.locator('button:has(ng-icon[name="lucideEdit"])').first();
|
|
||||||
const editTextarea = this.page.locator('textarea.edit-textarea').first();
|
|
||||||
const saveButton = this.page.locator('button:has(ng-icon[name="lucideCheck"])').first();
|
|
||||||
|
|
||||||
await expect(messageItem).toBeVisible({ timeout: 15_000 });
|
|
||||||
await messageItem.hover();
|
|
||||||
await editButton.click();
|
|
||||||
await expect(editTextarea).toBeVisible({ timeout: 10_000 });
|
|
||||||
await editTextarea.fill(updatedText);
|
|
||||||
await saveButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteOwnMessage(text: string): Promise<void> {
|
|
||||||
const messageItem = this.getMessageItemByText(text);
|
|
||||||
const deleteButton = messageItem.locator('button:has(ng-icon[name="lucideTrash2"])').first();
|
|
||||||
|
|
||||||
await expect(messageItem).toBeVisible({ timeout: 15_000 });
|
|
||||||
await messageItem.hover();
|
|
||||||
await deleteButton.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,39 +26,6 @@ export class ChatRoomPage {
|
|||||||
await channelButton.click();
|
await channelButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click a text channel by name in the channels sidebar to switch chat rooms. */
|
|
||||||
async joinTextChannel(channelName: string) {
|
|
||||||
const channelButton = this.getTextChannelButton(channelName);
|
|
||||||
|
|
||||||
if (await channelButton.count() === 0) {
|
|
||||||
await this.refreshRoomMetadata();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(channelButton).toBeVisible({ timeout: 15_000 });
|
|
||||||
await channelButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Creates a text channel and waits until it appears locally. */
|
|
||||||
async ensureTextChannelExists(channelName: string) {
|
|
||||||
const channelButton = this.getTextChannelButton(channelName);
|
|
||||||
|
|
||||||
if (await channelButton.count() > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.openCreateTextChannelDialog();
|
|
||||||
await this.createChannel(channelName);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expect(channelButton).toBeVisible({ timeout: 5_000 });
|
|
||||||
} catch {
|
|
||||||
await this.createTextChannelThroughComponent(channelName);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.persistCurrentChannelsToServer(channelName);
|
|
||||||
await expect(channelButton).toBeVisible({ timeout: 15_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Click "Create Voice Channel" button in the channels sidebar. */
|
/** Click "Create Voice Channel" button in the channels sidebar. */
|
||||||
async openCreateVoiceChannelDialog() {
|
async openCreateVoiceChannelDialog() {
|
||||||
await this.page.locator('button[title="Create Voice Channel"]').click();
|
await this.page.locator('button[title="Create Voice Channel"]').click();
|
||||||
@@ -77,17 +44,7 @@ export class ChatRoomPage {
|
|||||||
|
|
||||||
await expect(channelNameInput).toBeVisible({ timeout: 10_000 });
|
await expect(channelNameInput).toBeVisible({ timeout: 10_000 });
|
||||||
await channelNameInput.fill(name);
|
await channelNameInput.fill(name);
|
||||||
await channelNameInput.press('Enter');
|
await createButton.click();
|
||||||
|
|
||||||
if (await dialog.isVisible()) {
|
|
||||||
try {
|
|
||||||
await createButton.click();
|
|
||||||
} catch {
|
|
||||||
// Enter may already have confirmed and removed the dialog.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the voice controls component. */
|
/** Get the voice controls component. */
|
||||||
@@ -119,272 +76,4 @@ export class ChatRoomPage {
|
|||||||
|
|
||||||
return userAvatars.count();
|
return userAvatars.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the screen share toggle button inside voice controls. */
|
|
||||||
get screenShareButton() {
|
|
||||||
return this.voiceControls.locator(
|
|
||||||
'button:has(ng-icon[name="lucideMonitor"]), button:has(ng-icon[name="lucideMonitorOff"])'
|
|
||||||
).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Start screen sharing. Bypasses the quality dialog via localStorage preset. */
|
|
||||||
async startScreenShare() {
|
|
||||||
// Disable quality dialog so clicking the button starts sharing immediately
|
|
||||||
await this.page.evaluate(() => {
|
|
||||||
const key = 'metoyou_voice_settings';
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
const settings = raw ? JSON.parse(raw) : {};
|
|
||||||
|
|
||||||
settings.askScreenShareQuality = false;
|
|
||||||
settings.screenShareQuality = 'balanced';
|
|
||||||
localStorage.setItem(key, JSON.stringify(settings));
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.screenShareButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stop screen sharing by clicking the active screen share button. */
|
|
||||||
async stopScreenShare() {
|
|
||||||
await this.screenShareButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check whether the screen share button shows the active (MonitorOff) icon. */
|
|
||||||
get isScreenShareActive() {
|
|
||||||
return this.voiceControls.locator('button:has(ng-icon[name="lucideMonitorOff"])').first();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTextChannelButton(channelName: string): Locator {
|
|
||||||
const channelPattern = new RegExp(`#\\s*${escapeRegExp(channelName)}$`, 'i');
|
|
||||||
|
|
||||||
return this.channelsSidePanel.getByRole('button', { name: channelPattern }).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createTextChannelThroughComponent(channelName: string): Promise<void> {
|
|
||||||
await this.page.evaluate((name) => {
|
|
||||||
interface ChannelSidebarComponent {
|
|
||||||
createChannel: (type: 'text' | 'voice') => void;
|
|
||||||
newChannelName: string;
|
|
||||||
confirmCreateChannel: () => void;
|
|
||||||
}
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => ChannelSidebarComponent;
|
|
||||||
}
|
|
||||||
interface WindowWithAngularDebug extends Window {
|
|
||||||
ng?: AngularDebugApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as WindowWithAngularDebug).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
throw new Error('Angular debug API unavailable for text channel fallback');
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
|
|
||||||
component.createChannel('text');
|
|
||||||
component.newChannelName = name;
|
|
||||||
component.confirmCreateChannel();
|
|
||||||
}, channelName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistCurrentChannelsToServer(channelName: string): Promise<void> {
|
|
||||||
const result = await this.page.evaluate(async (requestedChannelName) => {
|
|
||||||
interface ServerEndpoint {
|
|
||||||
isActive?: boolean;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChannelShape {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: 'text' | 'voice';
|
|
||||||
position: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoomShape {
|
|
||||||
id: string;
|
|
||||||
sourceUrl?: string;
|
|
||||||
channels?: ChannelShape[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserShape {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChannelSidebarComponent {
|
|
||||||
currentRoom: () => RoomShape | null;
|
|
||||||
currentUser: () => UserShape | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => ChannelSidebarComponent;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WindowWithAngularDebug extends Window {
|
|
||||||
ng?: AngularDebugApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as WindowWithAngularDebug).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
throw new Error('Angular debug API unavailable for channel persistence');
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const room = component.currentRoom();
|
|
||||||
const currentUser = component.currentUser();
|
|
||||||
const endpoints = JSON.parse(localStorage.getItem('metoyou_server_endpoints') || '[]') as ServerEndpoint[];
|
|
||||||
const activeEndpoint = endpoints.find((endpoint) => endpoint.isActive) || endpoints[0] || null;
|
|
||||||
const apiBaseUrl = room?.sourceUrl || activeEndpoint?.url;
|
|
||||||
const normalizedChannelName = requestedChannelName.trim().replace(/\s+/g, ' ');
|
|
||||||
const existingChannels = Array.isArray(room?.channels) ? room.channels : [];
|
|
||||||
const hasTextChannel = existingChannels.some((channel) =>
|
|
||||||
channel.type === 'text' && channel.name.trim().toLowerCase() === normalizedChannelName.toLowerCase()
|
|
||||||
);
|
|
||||||
const nextChannels = hasTextChannel
|
|
||||||
? existingChannels
|
|
||||||
: [
|
|
||||||
...existingChannels,
|
|
||||||
{
|
|
||||||
id: globalThis.crypto.randomUUID(),
|
|
||||||
name: normalizedChannelName,
|
|
||||||
type: 'text' as const,
|
|
||||||
position: existingChannels.length
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!room?.id || !currentUser?.id || !apiBaseUrl) {
|
|
||||||
throw new Error('Missing room, user, or endpoint when persisting channels');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${apiBaseUrl}/api/servers/${room.id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
currentOwnerId: currentUser.id,
|
|
||||||
channels: nextChannels
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to persist channels: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { roomId: room.id, channels: nextChannels };
|
|
||||||
}, channelName);
|
|
||||||
|
|
||||||
// Update NGRX store directly so the UI reflects the new channel
|
|
||||||
// immediately, without waiting for an async effect round-trip.
|
|
||||||
await this.dispatchRoomChannelsUpdate(result.roomId, result.channels);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async dispatchRoomChannelsUpdate(
|
|
||||||
roomId: string,
|
|
||||||
channels: { id: string; name: string; type: string; position: number }[]
|
|
||||||
): Promise<void> {
|
|
||||||
await this.page.evaluate(({ rid, chs }) => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const store = component['store'] as { dispatch: (a: Record<string, unknown>) => void } | undefined;
|
|
||||||
|
|
||||||
if (store?.dispatch) {
|
|
||||||
store.dispatch({
|
|
||||||
type: '[Rooms] Update Room',
|
|
||||||
roomId: rid,
|
|
||||||
changes: { channels: chs }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, { rid: roomId, chs: channels });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshRoomMetadata(): Promise<void> {
|
|
||||||
await this.page.evaluate(async () => {
|
|
||||||
interface ServerEndpoint {
|
|
||||||
isActive?: boolean;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChannelShape {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: 'text' | 'voice';
|
|
||||||
position: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WindowWithAngularDebug extends Window {
|
|
||||||
ng?: AngularDebugApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as WindowWithAngularDebug).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
throw new Error('Angular debug API unavailable for room refresh');
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const currentRoom = typeof component['currentRoom'] === 'function'
|
|
||||||
? (component['currentRoom'] as () => { id: string; sourceUrl?: string; channels?: ChannelShape[] } | null)()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!currentRoom) {
|
|
||||||
throw new Error('No current room to refresh');
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = component['store'] as { dispatch: (action: Record<string, unknown>) => void } | undefined;
|
|
||||||
|
|
||||||
if (!store?.dispatch) {
|
|
||||||
throw new Error('NGRX store not available on component');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch server data directly via REST API instead of triggering
|
|
||||||
// an async NGRX effect that can race with pending writes.
|
|
||||||
const endpoints = JSON.parse(localStorage.getItem('metoyou_server_endpoints') || '[]') as ServerEndpoint[];
|
|
||||||
const activeEndpoint = endpoints.find((ep) => ep.isActive) || endpoints[0] || null;
|
|
||||||
const apiBaseUrl = currentRoom.sourceUrl || activeEndpoint?.url;
|
|
||||||
|
|
||||||
if (!apiBaseUrl) {
|
|
||||||
throw new Error('No API base URL available for room refresh');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${apiBaseUrl}/api/servers/${currentRoom.id}`);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const serverData = await response.json() as { channels?: ChannelShape[] };
|
|
||||||
|
|
||||||
if (serverData.channels?.length) {
|
|
||||||
store.dispatch({
|
|
||||||
type: '[Rooms] Update Room',
|
|
||||||
roomId: currentRoom.id,
|
|
||||||
changes: { channels: serverData.channels }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Brief wait for Angular change detection to propagate
|
|
||||||
await this.page.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,17 +22,7 @@ export class RegisterPage {
|
|||||||
async goto() {
|
async goto() {
|
||||||
await this.page.goto('/register', { waitUntil: 'domcontentloaded' });
|
await this.page.goto('/register', { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
try {
|
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(this.usernameInput).toBeVisible({ timeout: 10_000 });
|
|
||||||
} catch {
|
|
||||||
// Angular router may redirect to /login on first load; click through.
|
|
||||||
const registerLink = this.page.getByRole('link', { name: 'Register' })
|
|
||||||
.or(this.page.getByText('Register'));
|
|
||||||
|
|
||||||
await registerLink.first().click();
|
|
||||||
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(this.submitButton).toBeVisible({ timeout: 30_000 });
|
await expect(this.submitButton).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const TEST_SERVER_PORT = Number(process.env.TEST_SERVER_PORT) || 3099;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
timeout: 90_000,
|
timeout: 90_000,
|
||||||
@@ -30,10 +32,21 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: [
|
||||||
command: 'cd ../toju-app && npx ng serve',
|
{
|
||||||
port: 4200,
|
// Isolated test server with its own temporary database.
|
||||||
reuseExistingServer: !process.env.CI,
|
// See e2e/helpers/start-test-server.js for details.
|
||||||
timeout: 120_000,
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,295 +0,0 @@
|
|||||||
import { type Page } from '@playwright/test';
|
|
||||||
import { test, expect, type Client } from '../../fixtures/multi-client';
|
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
|
||||||
import {
|
|
||||||
ChatMessagesPage,
|
|
||||||
type ChatDropFilePayload
|
|
||||||
} from '../../pages/chat-messages.page';
|
|
||||||
|
|
||||||
const MOCK_EMBED_URL = 'https://example.test/mock-embed';
|
|
||||||
const MOCK_EMBED_TITLE = 'Mock Embed Title';
|
|
||||||
const MOCK_EMBED_DESCRIPTION = 'Mock embed description for chat E2E coverage.';
|
|
||||||
const MOCK_GIF_IMAGE_URL = 'data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
|
||||||
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
|
||||||
|
|
||||||
test.describe('Chat messaging features', () => {
|
|
||||||
test.describe.configure({ timeout: 180_000 });
|
|
||||||
|
|
||||||
test('syncs messages in a newly created text channel', async ({ createClient }) => {
|
|
||||||
const scenario = await createChatScenario(createClient);
|
|
||||||
const channelName = uniqueName('updates');
|
|
||||||
const aliceMessage = `Alice text channel message ${uniqueName('msg')}`;
|
|
||||||
const bobMessage = `Bob text channel reply ${uniqueName('msg')}`;
|
|
||||||
|
|
||||||
await test.step('Alice creates a new text channel and both users join it', async () => {
|
|
||||||
await scenario.aliceRoom.ensureTextChannelExists(channelName);
|
|
||||||
await scenario.aliceRoom.joinTextChannel(channelName);
|
|
||||||
await scenario.bobRoom.joinTextChannel(channelName);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Alice and Bob see synced messages in the new text channel', async () => {
|
|
||||||
await scenario.aliceMessages.sendMessage(aliceMessage);
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(aliceMessage)).toBeVisible({ timeout: 20_000 });
|
|
||||||
|
|
||||||
await scenario.bobMessages.sendMessage(bobMessage);
|
|
||||||
await expect(scenario.aliceMessages.getMessageItemByText(bobMessage)).toBeVisible({ timeout: 20_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows typing indicators to other users', async ({ createClient }) => {
|
|
||||||
const scenario = await createChatScenario(createClient);
|
|
||||||
const draftMessage = `Typing indicator draft ${uniqueName('draft')}`;
|
|
||||||
|
|
||||||
await test.step('Alice starts typing in general channel', async () => {
|
|
||||||
await scenario.aliceMessages.typeDraft(draftMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Bob sees Alice typing', async () => {
|
|
||||||
await expect(scenario.bob.page.getByText('Alice is typing...')).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('edits and removes messages for both users', async ({ createClient }) => {
|
|
||||||
const scenario = await createChatScenario(createClient);
|
|
||||||
const originalMessage = `Editable message ${uniqueName('edit')}`;
|
|
||||||
const updatedMessage = `Edited message ${uniqueName('edit')}`;
|
|
||||||
|
|
||||||
await test.step('Alice sends a message and Bob receives it', async () => {
|
|
||||||
await scenario.aliceMessages.sendMessage(originalMessage);
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(originalMessage)).toBeVisible({ timeout: 20_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Alice edits the message and both users see updated content', async () => {
|
|
||||||
await scenario.aliceMessages.editOwnMessage(originalMessage, updatedMessage);
|
|
||||||
await expect(scenario.aliceMessages.getMessageItemByText(updatedMessage)).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect(scenario.alice.page.getByText('(edited)')).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(updatedMessage)).toBeVisible({ timeout: 20_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Alice deletes the message and both users see deletion state', async () => {
|
|
||||||
await scenario.aliceMessages.deleteOwnMessage(updatedMessage);
|
|
||||||
await expect(scenario.aliceMessages.getMessageItemByText(DELETED_MESSAGE_CONTENT)).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE_CONTENT)).toBeVisible({ timeout: 20_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('syncs image and file attachments between users', async ({ createClient }) => {
|
|
||||||
const scenario = await createChatScenario(createClient);
|
|
||||||
const imageName = `${uniqueName('diagram')}.svg`;
|
|
||||||
const fileName = `${uniqueName('notes')}.txt`;
|
|
||||||
const imageCaption = `Image upload ${uniqueName('caption')}`;
|
|
||||||
const fileCaption = `File upload ${uniqueName('caption')}`;
|
|
||||||
const imageAttachment = createTextFilePayload(imageName, 'image/svg+xml', buildMockSvgMarkup(imageName));
|
|
||||||
const fileAttachment = createTextFilePayload(fileName, 'text/plain', `Attachment body for ${fileName}`);
|
|
||||||
|
|
||||||
await test.step('Alice sends image attachment and Bob receives it', async () => {
|
|
||||||
await scenario.aliceMessages.attachFiles([imageAttachment]);
|
|
||||||
await scenario.aliceMessages.sendMessage(imageCaption);
|
|
||||||
|
|
||||||
await scenario.aliceMessages.expectMessageImageLoaded(imageName);
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(imageCaption)).toBeVisible({ timeout: 20_000 });
|
|
||||||
await scenario.bobMessages.expectMessageImageLoaded(imageName);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Alice sends generic file attachment and Bob receives it', async () => {
|
|
||||||
await scenario.aliceMessages.attachFiles([fileAttachment]);
|
|
||||||
await scenario.aliceMessages.sendMessage(fileCaption);
|
|
||||||
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(fileCaption)).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect(scenario.bob.page.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders link embeds for shared links', async ({ createClient }) => {
|
|
||||||
const scenario = await createChatScenario(createClient);
|
|
||||||
const messageText = `Useful docs ${MOCK_EMBED_URL}`;
|
|
||||||
|
|
||||||
await test.step('Alice shares a link in chat', async () => {
|
|
||||||
await scenario.aliceMessages.sendMessage(messageText);
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Both users see mocked link embed metadata', async () => {
|
|
||||||
await expect(scenario.aliceMessages.getEmbedCardByTitle(MOCK_EMBED_TITLE)).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect(scenario.bobMessages.getEmbedCardByTitle(MOCK_EMBED_TITLE)).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect(scenario.bob.page.getByText(MOCK_EMBED_DESCRIPTION)).toBeVisible({ timeout: 20_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sends KLIPY GIF messages with mocked API responses', async ({ createClient }) => {
|
|
||||||
const scenario = await createChatScenario(createClient);
|
|
||||||
|
|
||||||
await test.step('Alice opens GIF picker and sends mocked GIF', async () => {
|
|
||||||
await scenario.aliceMessages.openGifPicker();
|
|
||||||
await scenario.aliceMessages.selectFirstGif();
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Bob sees GIF message sync', async () => {
|
|
||||||
await scenario.aliceMessages.expectMessageImageLoaded('KLIPY GIF');
|
|
||||||
await scenario.bobMessages.expectMessageImageLoaded('KLIPY GIF');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
type ChatScenario = {
|
|
||||||
alice: Client;
|
|
||||||
bob: Client;
|
|
||||||
aliceRoom: ChatRoomPage;
|
|
||||||
bobRoom: ChatRoomPage;
|
|
||||||
aliceMessages: ChatMessagesPage;
|
|
||||||
bobMessages: ChatMessagesPage;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> {
|
|
||||||
const suffix = uniqueName('chat');
|
|
||||||
const serverName = `Chat Server ${suffix}`;
|
|
||||||
const aliceCredentials = {
|
|
||||||
username: `alice_${suffix}`,
|
|
||||||
displayName: 'Alice',
|
|
||||||
password: 'TestPass123!'
|
|
||||||
};
|
|
||||||
const bobCredentials = {
|
|
||||||
username: `bob_${suffix}`,
|
|
||||||
displayName: 'Bob',
|
|
||||||
password: 'TestPass123!'
|
|
||||||
};
|
|
||||||
const alice = await createClient();
|
|
||||||
const bob = await createClient();
|
|
||||||
|
|
||||||
await installChatFeatureMocks(alice.page);
|
|
||||||
await installChatFeatureMocks(bob.page);
|
|
||||||
|
|
||||||
const aliceRegisterPage = new RegisterPage(alice.page);
|
|
||||||
const bobRegisterPage = new RegisterPage(bob.page);
|
|
||||||
|
|
||||||
await aliceRegisterPage.goto();
|
|
||||||
await aliceRegisterPage.register(
|
|
||||||
aliceCredentials.username,
|
|
||||||
aliceCredentials.displayName,
|
|
||||||
aliceCredentials.password
|
|
||||||
);
|
|
||||||
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
|
||||||
|
|
||||||
await bobRegisterPage.goto();
|
|
||||||
await bobRegisterPage.register(
|
|
||||||
bobCredentials.username,
|
|
||||||
bobCredentials.displayName,
|
|
||||||
bobCredentials.password
|
|
||||||
);
|
|
||||||
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
|
||||||
|
|
||||||
const aliceSearchPage = new ServerSearchPage(alice.page);
|
|
||||||
|
|
||||||
await aliceSearchPage.createServer(serverName, {
|
|
||||||
description: 'E2E chat server for messaging feature coverage'
|
|
||||||
});
|
|
||||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
|
||||||
|
|
||||||
const bobSearchPage = new ServerSearchPage(bob.page);
|
|
||||||
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
|
|
||||||
|
|
||||||
await bobSearchPage.searchInput.fill(serverName);
|
|
||||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
|
||||||
await serverCard.click();
|
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
|
||||||
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
|
||||||
const bobRoom = new ChatRoomPage(bob.page);
|
|
||||||
const aliceMessages = new ChatMessagesPage(alice.page);
|
|
||||||
const bobMessages = new ChatMessagesPage(bob.page);
|
|
||||||
|
|
||||||
await aliceMessages.waitForReady();
|
|
||||||
await bobMessages.waitForReady();
|
|
||||||
|
|
||||||
return {
|
|
||||||
alice,
|
|
||||||
bob,
|
|
||||||
aliceRoom,
|
|
||||||
bobRoom,
|
|
||||||
aliceMessages,
|
|
||||||
bobMessages
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installChatFeatureMocks(page: Page): Promise<void> {
|
|
||||||
await page.route('**/api/klipy/config', async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ enabled: true })
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route('**/api/klipy/gifs**', async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: true,
|
|
||||||
hasNext: false,
|
|
||||||
results: [
|
|
||||||
{
|
|
||||||
id: 'mock-gif-1',
|
|
||||||
slug: 'mock-gif-1',
|
|
||||||
title: 'Mock Celebration GIF',
|
|
||||||
url: MOCK_GIF_IMAGE_URL,
|
|
||||||
previewUrl: MOCK_GIF_IMAGE_URL,
|
|
||||||
width: 64,
|
|
||||||
height: 64
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route('**/api/link-metadata**', async (route) => {
|
|
||||||
const requestUrl = new URL(route.request().url());
|
|
||||||
const requestedTargetUrl = requestUrl.searchParams.get('url') ?? '';
|
|
||||||
|
|
||||||
if (requestedTargetUrl === MOCK_EMBED_URL) {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: MOCK_EMBED_TITLE,
|
|
||||||
description: MOCK_EMBED_DESCRIPTION,
|
|
||||||
imageUrl: MOCK_GIF_IMAGE_URL,
|
|
||||||
siteName: 'Mock Docs'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ failed: true })
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTextFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
mimeType,
|
|
||||||
base64: Buffer.from(content, 'utf8').toString('base64')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMockSvgMarkup(label: string): string {
|
|
||||||
return [
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
|
|
||||||
'<rect width="160" height="120" rx="18" fill="#0f172a" />',
|
|
||||||
'<circle cx="38" cy="36" r="18" fill="#38bdf8" />',
|
|
||||||
'<rect x="66" y="28" width="64" height="16" rx="8" fill="#f8fafc" />',
|
|
||||||
'<rect x="24" y="74" width="112" height="12" rx="6" fill="#22c55e" />',
|
|
||||||
`<text x="24" y="104" fill="#e2e8f0" font-size="12" font-family="Arial, sans-serif">${label}</text>`,
|
|
||||||
'</svg>'
|
|
||||||
].join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueName(prefix: string): string {
|
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
}
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
import { test, expect } from '../../fixtures/multi-client';
|
|
||||||
import {
|
|
||||||
installWebRTCTracking,
|
|
||||||
waitForPeerConnected,
|
|
||||||
isPeerStillConnected,
|
|
||||||
waitForAudioFlow,
|
|
||||||
waitForAudioStatsPresent,
|
|
||||||
waitForVideoFlow,
|
|
||||||
waitForOutboundVideoFlow,
|
|
||||||
waitForInboundVideoFlow,
|
|
||||||
dumpRtcDiagnostics
|
|
||||||
} from '../../helpers/webrtc-helpers';
|
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Screen sharing E2E tests: verify video, screen-share audio, and voice audio
|
|
||||||
* flow correctly between users during screen sharing.
|
|
||||||
*
|
|
||||||
* Uses the same dedicated-browser-per-client infrastructure as voice tests.
|
|
||||||
* getDisplayMedia is monkey-patched to return a synthetic canvas video stream
|
|
||||||
* + 880 Hz oscillator audio, bypassing the browser picker dialog.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const ALICE = { username: `alice_ss_${Date.now()}`, displayName: 'Alice', password: 'TestPass123!' };
|
|
||||||
const BOB = { username: `bob_ss_${Date.now()}`, displayName: 'Bob', password: 'TestPass123!' };
|
|
||||||
const SERVER_NAME = `SS Test ${Date.now()}`;
|
|
||||||
const VOICE_CHANNEL = 'General';
|
|
||||||
|
|
||||||
/** Register a user and navigate to /search. */
|
|
||||||
async function registerUser(page: import('@playwright/test').Page, user: typeof ALICE) {
|
|
||||||
const registerPage = new RegisterPage(page);
|
|
||||||
|
|
||||||
await registerPage.goto();
|
|
||||||
await expect(registerPage.submitButton).toBeVisible();
|
|
||||||
await registerPage.register(user.username, user.displayName, user.password);
|
|
||||||
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Both users register → Alice creates server → Bob joins. */
|
|
||||||
async function setupServerWithBothUsers(
|
|
||||||
alice: { page: import('@playwright/test').Page },
|
|
||||||
bob: { page: import('@playwright/test').Page }
|
|
||||||
) {
|
|
||||||
await registerUser(alice.page, ALICE);
|
|
||||||
await registerUser(bob.page, BOB);
|
|
||||||
|
|
||||||
// Alice creates server
|
|
||||||
const aliceSearch = new ServerSearchPage(alice.page);
|
|
||||||
|
|
||||||
await aliceSearch.createServer(SERVER_NAME, { description: 'Screen share E2E' });
|
|
||||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
|
||||||
|
|
||||||
// Bob joins server
|
|
||||||
const bobSearch = new ServerSearchPage(bob.page);
|
|
||||||
|
|
||||||
await bobSearch.searchInput.fill(SERVER_NAME);
|
|
||||||
|
|
||||||
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
|
|
||||||
|
|
||||||
await expect(serverCard).toBeVisible({ timeout: 10_000 });
|
|
||||||
await serverCard.click();
|
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ensure voice channel exists and both users join it. */
|
|
||||||
async function joinVoiceTogether(
|
|
||||||
alice: { page: import('@playwright/test').Page },
|
|
||||||
bob: { page: import('@playwright/test').Page }
|
|
||||||
) {
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
|
||||||
const existingChannel = alice.page
|
|
||||||
.locator('app-rooms-side-panel')
|
|
||||||
.getByRole('button', { name: VOICE_CHANNEL, exact: true });
|
|
||||||
|
|
||||||
if (await existingChannel.count() === 0) {
|
|
||||||
await aliceRoom.openCreateVoiceChannelDialog();
|
|
||||||
await aliceRoom.createChannel(VOICE_CHANNEL);
|
|
||||||
await expect(existingChannel).toBeVisible({ timeout: 10_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
|
|
||||||
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
const bobRoom = new ChatRoomPage(bob.page);
|
|
||||||
|
|
||||||
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
|
|
||||||
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// Wait for WebRTC + audio pipeline
|
|
||||||
await waitForPeerConnected(alice.page, 30_000);
|
|
||||||
await waitForPeerConnected(bob.page, 30_000);
|
|
||||||
await waitForAudioStatsPresent(alice.page, 20_000);
|
|
||||||
await waitForAudioStatsPresent(bob.page, 20_000);
|
|
||||||
|
|
||||||
// Expand voice workspace on both clients so the demand-driven screen
|
|
||||||
// share request flow can fire (requires connectRemoteShares = true).
|
|
||||||
// Click the "VIEW" badge that appears next to the active voice channel.
|
|
||||||
const aliceView = alice.page.locator('app-rooms-side-panel')
|
|
||||||
.getByRole('button', { name: /view/i })
|
|
||||||
.first();
|
|
||||||
const bobView = bob.page.locator('app-rooms-side-panel')
|
|
||||||
.getByRole('button', { name: /view/i })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
await expect(aliceView).toBeVisible({ timeout: 10_000 });
|
|
||||||
await aliceView.click();
|
|
||||||
await expect(alice.page.locator('app-voice-workspace')).toBeVisible({ timeout: 10_000 });
|
|
||||||
|
|
||||||
await expect(bobView).toBeVisible({ timeout: 10_000 });
|
|
||||||
await bobView.click();
|
|
||||||
await expect(bob.page.locator('app-voice-workspace')).toBeVisible({ timeout: 10_000 });
|
|
||||||
|
|
||||||
// Re-verify audio stats are present after workspace expansion (the VIEW
|
|
||||||
// click can trigger renegotiation which briefly disrupts audio).
|
|
||||||
await waitForAudioStatsPresent(alice.page, 20_000);
|
|
||||||
await waitForAudioStatsPresent(bob.page, 20_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectFlowing(
|
|
||||||
delta: { outboundBytesDelta: number; inboundBytesDelta: number; outboundPacketsDelta: number; inboundPacketsDelta: number },
|
|
||||||
label: string
|
|
||||||
) {
|
|
||||||
expect(
|
|
||||||
delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0,
|
|
||||||
`${label} should be sending`
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0,
|
|
||||||
`${label} should be receiving`
|
|
||||||
).toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Screen sharing', () => {
|
|
||||||
test('single user screen share: video and audio flow to receiver, voice audio continues', async ({ createClient }) => {
|
|
||||||
test.setTimeout(180_000);
|
|
||||||
|
|
||||||
const alice = await createClient();
|
|
||||||
const bob = await createClient();
|
|
||||||
|
|
||||||
await installWebRTCTracking(alice.page);
|
|
||||||
await installWebRTCTracking(bob.page);
|
|
||||||
|
|
||||||
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
|
||||||
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
|
||||||
|
|
||||||
// ── Setup: register, server, voice ────────────────────────────
|
|
||||||
|
|
||||||
await test.step('Setup server and voice channel', async () => {
|
|
||||||
await setupServerWithBothUsers(alice, bob);
|
|
||||||
await joinVoiceTogether(alice, bob);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Verify voice audio before screen share ────────────────────
|
|
||||||
|
|
||||||
await test.step('Voice audio flows before screen share', async () => {
|
|
||||||
const aliceDelta = await waitForAudioFlow(alice.page, 30_000);
|
|
||||||
const bobDelta = await waitForAudioFlow(bob.page, 30_000);
|
|
||||||
|
|
||||||
expectFlowing(aliceDelta, 'Alice voice');
|
|
||||||
expectFlowing(bobDelta, 'Bob voice');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Alice starts screen sharing ───────────────────────────────
|
|
||||||
|
|
||||||
await test.step('Alice starts screen sharing', async () => {
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
|
||||||
|
|
||||||
await aliceRoom.startScreenShare();
|
|
||||||
|
|
||||||
// Screen share button should show active state (MonitorOff icon)
|
|
||||||
await expect(aliceRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Verify screen share video flows ───────────────────────────
|
|
||||||
|
|
||||||
await test.step('Screen share video flows from Alice to Bob', async () => {
|
|
||||||
// Screen share is unidirectional: Alice sends video, Bob receives it.
|
|
||||||
const aliceVideo = await waitForOutboundVideoFlow(alice.page, 30_000);
|
|
||||||
const bobVideo = await waitForInboundVideoFlow(bob.page, 30_000);
|
|
||||||
|
|
||||||
if (aliceVideo.outboundBytesDelta === 0 || bobVideo.inboundBytesDelta === 0) {
|
|
||||||
console.log('[Alice RTC]\n' + await dumpRtcDiagnostics(alice.page));
|
|
||||||
console.log('[Bob RTC]\n' + await dumpRtcDiagnostics(bob.page));
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(
|
|
||||||
aliceVideo.outboundBytesDelta > 0 || aliceVideo.outboundPacketsDelta > 0,
|
|
||||||
'Alice should be sending screen share video'
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
bobVideo.inboundBytesDelta > 0 || bobVideo.inboundPacketsDelta > 0,
|
|
||||||
'Bob should be receiving screen share video'
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Verify voice audio continues during screen share ──────────
|
|
||||||
|
|
||||||
await test.step('Voice audio continues during screen share', async () => {
|
|
||||||
const aliceAudio = await waitForAudioFlow(alice.page, 20_000);
|
|
||||||
const bobAudio = await waitForAudioFlow(bob.page, 20_000);
|
|
||||||
|
|
||||||
expectFlowing(aliceAudio, 'Alice voice during screen share');
|
|
||||||
expectFlowing(bobAudio, 'Bob voice during screen share');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Bob can hear Alice talk while she screen shares ───────────
|
|
||||||
|
|
||||||
await test.step('Bob receives audio from Alice during screen share', async () => {
|
|
||||||
// Specifically check Bob is receiving audio (from Alice's voice)
|
|
||||||
const bobAudio = await waitForAudioFlow(bob.page, 15_000);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
bobAudio.inboundBytesDelta > 0,
|
|
||||||
'Bob should receive voice audio while Alice screen shares'
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Alice stops screen sharing ────────────────────────────────
|
|
||||||
|
|
||||||
await test.step('Alice stops screen sharing', async () => {
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
|
||||||
|
|
||||||
await aliceRoom.stopScreenShare();
|
|
||||||
|
|
||||||
// Active icon should disappear - regular Monitor icon shown instead
|
|
||||||
await expect(
|
|
||||||
aliceRoom.voiceControls.locator('button:has(ng-icon[name="lucideMonitor"])').first()
|
|
||||||
).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Voice audio still works after screen share ends ───────────
|
|
||||||
|
|
||||||
await test.step('Voice audio resumes normally after screen share stops', async () => {
|
|
||||||
const aliceAudio = await waitForAudioFlow(alice.page, 20_000);
|
|
||||||
const bobAudio = await waitForAudioFlow(bob.page, 20_000);
|
|
||||||
|
|
||||||
expectFlowing(aliceAudio, 'Alice voice after screen share');
|
|
||||||
expectFlowing(bobAudio, 'Bob voice after screen share');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('multiple users screen share simultaneously', async ({ createClient }) => {
|
|
||||||
test.setTimeout(180_000);
|
|
||||||
|
|
||||||
const alice = await createClient();
|
|
||||||
const bob = await createClient();
|
|
||||||
|
|
||||||
await installWebRTCTracking(alice.page);
|
|
||||||
await installWebRTCTracking(bob.page);
|
|
||||||
|
|
||||||
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
|
||||||
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
|
||||||
|
|
||||||
await test.step('Setup server and voice channel', async () => {
|
|
||||||
await setupServerWithBothUsers(alice, bob);
|
|
||||||
await joinVoiceTogether(alice, bob);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Both users start screen sharing ───────────────────────────
|
|
||||||
|
|
||||||
await test.step('Alice starts screen sharing', async () => {
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
|
||||||
|
|
||||||
await aliceRoom.startScreenShare();
|
|
||||||
await expect(aliceRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Bob starts screen sharing', async () => {
|
|
||||||
const bobRoom = new ChatRoomPage(bob.page);
|
|
||||||
|
|
||||||
await bobRoom.startScreenShare();
|
|
||||||
await expect(bobRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Verify video flows in both directions ─────────────────────
|
|
||||||
|
|
||||||
await test.step('Video flows bidirectionally with both screen shares active', async () => {
|
|
||||||
// Both sharing: each page sends and receives video
|
|
||||||
const aliceVideo = await waitForVideoFlow(alice.page, 30_000);
|
|
||||||
const bobVideo = await waitForVideoFlow(bob.page, 30_000);
|
|
||||||
|
|
||||||
expectFlowing(aliceVideo, 'Alice screen share video');
|
|
||||||
expectFlowing(bobVideo, 'Bob screen share video');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Voice audio continues with dual screen shares ─────────────
|
|
||||||
|
|
||||||
await test.step('Voice audio continues with both users screen sharing', async () => {
|
|
||||||
const aliceAudio = await waitForAudioFlow(alice.page, 20_000);
|
|
||||||
const bobAudio = await waitForAudioFlow(bob.page, 20_000);
|
|
||||||
|
|
||||||
expectFlowing(aliceAudio, 'Alice voice during dual screen share');
|
|
||||||
expectFlowing(bobAudio, 'Bob voice during dual screen share');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Both stop screen sharing ──────────────────────────────────
|
|
||||||
|
|
||||||
await test.step('Both users stop screen sharing', async () => {
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
|
||||||
const bobRoom = new ChatRoomPage(bob.page);
|
|
||||||
|
|
||||||
await aliceRoom.stopScreenShare();
|
|
||||||
await expect(
|
|
||||||
aliceRoom.voiceControls.locator('button:has(ng-icon[name="lucideMonitor"])').first()
|
|
||||||
).toBeVisible({ timeout: 10_000 });
|
|
||||||
|
|
||||||
await bobRoom.stopScreenShare();
|
|
||||||
await expect(
|
|
||||||
bobRoom.voiceControls.locator('button:has(ng-icon[name="lucideMonitor"])').first()
|
|
||||||
).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('screen share connection stays stable for 10+ seconds', async ({ createClient }) => {
|
|
||||||
test.setTimeout(180_000);
|
|
||||||
|
|
||||||
const alice = await createClient();
|
|
||||||
const bob = await createClient();
|
|
||||||
|
|
||||||
await installWebRTCTracking(alice.page);
|
|
||||||
await installWebRTCTracking(bob.page);
|
|
||||||
|
|
||||||
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
|
||||||
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
|
||||||
|
|
||||||
await test.step('Setup server and voice channel', async () => {
|
|
||||||
await setupServerWithBothUsers(alice, bob);
|
|
||||||
await joinVoiceTogether(alice, bob);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Alice starts screen sharing', async () => {
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
|
||||||
|
|
||||||
await aliceRoom.startScreenShare();
|
|
||||||
await expect(aliceRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 });
|
|
||||||
|
|
||||||
// Wait for video pipeline to fully establish
|
|
||||||
await waitForOutboundVideoFlow(alice.page, 30_000);
|
|
||||||
await waitForInboundVideoFlow(bob.page, 30_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Stability checkpoints at 0s, 5s, 10s ─────────────────────
|
|
||||||
|
|
||||||
await test.step('Connection stays stable for 10+ seconds during screen share', async () => {
|
|
||||||
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 - verify both video and audio still flowing
|
|
||||||
const aliceVideo = await waitForOutboundVideoFlow(alice.page, 15_000);
|
|
||||||
const bobVideo = await waitForInboundVideoFlow(bob.page, 15_000);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
aliceVideo.outboundBytesDelta > 0,
|
|
||||||
'Alice still sending screen share video after 10s'
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
bobVideo.inboundBytesDelta > 0,
|
|
||||||
'Bob still receiving screen share video after 10s'
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
const aliceAudio = await waitForAudioFlow(alice.page, 15_000);
|
|
||||||
const bobAudio = await waitForAudioFlow(bob.page, 15_000);
|
|
||||||
|
|
||||||
expectFlowing(aliceAudio, 'Alice voice after 10s screen share');
|
|
||||||
expectFlowing(bobAudio, 'Bob voice after 10s screen share');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Clean disconnect ──────────────────────────────────────────
|
|
||||||
|
|
||||||
await test.step('Alice stops screen share and disconnects', async () => {
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
|
||||||
|
|
||||||
await aliceRoom.stopScreenShare();
|
|
||||||
await aliceRoom.disconnectButton.click();
|
|
||||||
await expect(aliceRoom.disconnectButton).not.toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,10 +3,7 @@ import {
|
|||||||
installWebRTCTracking,
|
installWebRTCTracking,
|
||||||
waitForPeerConnected,
|
waitForPeerConnected,
|
||||||
isPeerStillConnected,
|
isPeerStillConnected,
|
||||||
getAudioStatsDelta,
|
getAudioStatsDelta
|
||||||
waitForAudioFlow,
|
|
||||||
waitForAudioStatsPresent,
|
|
||||||
dumpRtcDiagnostics
|
|
||||||
} from '../../helpers/webrtc-helpers';
|
} from '../../helpers/webrtc-helpers';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
@@ -136,27 +133,21 @@ test.describe('Full user journey: register → server → voice chat', () => {
|
|||||||
await test.step('WebRTC peer connection reaches "connected" state', async () => {
|
await test.step('WebRTC peer connection reaches "connected" state', async () => {
|
||||||
await waitForPeerConnected(alice.page, 30_000);
|
await waitForPeerConnected(alice.page, 30_000);
|
||||||
await waitForPeerConnected(bob.page, 30_000);
|
await waitForPeerConnected(bob.page, 30_000);
|
||||||
|
|
||||||
// Wait for audio RTP pipeline to appear before measuring deltas -
|
|
||||||
// renegotiation after initial connect can temporarily remove stats.
|
|
||||||
await waitForAudioStatsPresent(alice.page, 20_000);
|
|
||||||
await waitForAudioStatsPresent(bob.page, 20_000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Step 7: Verify audio is flowing in both directions ───────────
|
// ── Step 7: Verify audio is flowing in both directions ───────────
|
||||||
|
|
||||||
await test.step('Audio packets are flowing between Alice and Bob', async () => {
|
await test.step('Audio packets are flowing between Alice and Bob', async () => {
|
||||||
const aliceDelta = await waitForAudioFlow(alice.page, 30_000);
|
// Wait a moment for audio pipeline to stabilize
|
||||||
const bobDelta = await waitForAudioFlow(bob.page, 30_000);
|
const aliceDelta = await getAudioStatsDelta(alice.page, 3_000);
|
||||||
|
|
||||||
if (aliceDelta.outboundBytesDelta === 0 || aliceDelta.inboundBytesDelta === 0
|
expect(aliceDelta.outboundBytesDelta).toBeGreaterThan(0);
|
||||||
|| bobDelta.outboundBytesDelta === 0 || bobDelta.inboundBytesDelta === 0) {
|
expect(aliceDelta.inboundBytesDelta).toBeGreaterThan(0);
|
||||||
console.log('[Alice RTC Diagnostics]\n' + await dumpRtcDiagnostics(alice.page));
|
|
||||||
console.log('[Bob RTC Diagnostics]\n' + await dumpRtcDiagnostics(bob.page));
|
|
||||||
}
|
|
||||||
|
|
||||||
expectAudioFlow(aliceDelta, 'Alice');
|
const bobDelta = await getAudioStatsDelta(bob.page, 3_000);
|
||||||
expectAudioFlow(bobDelta, 'Bob');
|
|
||||||
|
expect(bobDelta.outboundBytesDelta).toBeGreaterThan(0);
|
||||||
|
expect(bobDelta.inboundBytesDelta).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Step 8: Verify UI states are correct ─────────────────────────
|
// ── Step 8: Verify UI states are correct ─────────────────────────
|
||||||
@@ -199,11 +190,15 @@ test.describe('Full user journey: register → server → voice chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After 10s total, verify audio is still flowing
|
// After 10s total, verify audio is still flowing
|
||||||
const aliceDelta = await waitForAudioFlow(alice.page, 15_000);
|
const aliceDelta = await getAudioStatsDelta(alice.page, 2_000);
|
||||||
const bobDelta = await waitForAudioFlow(bob.page, 15_000);
|
|
||||||
|
|
||||||
expectAudioFlow(aliceDelta, 'Alice after 10s');
|
expect(aliceDelta.outboundBytesDelta, 'Alice should still be sending audio after 10s').toBeGreaterThan(0);
|
||||||
expectAudioFlow(bobDelta, 'Bob after 10s');
|
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 ──────────────────
|
// ── Step 10: Verify mute/unmute works correctly ──────────────────
|
||||||
@@ -223,9 +218,9 @@ test.describe('Full user journey: register → server → voice chat', () => {
|
|||||||
await aliceRoom.muteButton.click();
|
await aliceRoom.muteButton.click();
|
||||||
|
|
||||||
// After unmuting, outbound should resume
|
// After unmuting, outbound should resume
|
||||||
const unmutedDelta = await waitForAudioFlow(alice.page, 15_000);
|
const unmutedDelta = await getAudioStatsDelta(alice.page, 2_000);
|
||||||
|
|
||||||
expectAudioFlow(unmutedDelta, 'Alice after unmuting');
|
expect(unmutedDelta.outboundBytesDelta, 'Audio should flow after unmuting').toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Step 11: Clean disconnect ────────────────────────────────────
|
// ── Step 11: Clean disconnect ────────────────────────────────────
|
||||||
@@ -241,20 +236,3 @@ test.describe('Full user journey: register → server → voice chat', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function expectAudioFlow(delta: {
|
|
||||||
outboundBytesDelta: number;
|
|
||||||
inboundBytesDelta: number;
|
|
||||||
outboundPacketsDelta: number;
|
|
||||||
inboundPacketsDelta: number;
|
|
||||||
}, label: string): void {
|
|
||||||
expect(
|
|
||||||
delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0,
|
|
||||||
`${label} should be sending audio`
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0,
|
|
||||||
`${label} should be receiving audio`
|
|
||||||
).toBe(true);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -146,13 +146,9 @@
|
|||||||
"output": "dist-electron"
|
"output": "dist-electron"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"!node_modules",
|
|
||||||
"dist/client/**/*",
|
"dist/client/**/*",
|
||||||
"dist/electron/**/*",
|
"dist/electron/**/*",
|
||||||
"node_modules/{ansi-regex,ansi-styles,ansis,app-root-path,applescript,argparse,auto-launch,available-typed-arrays,balanced-match,base64-js,brace-expansion,buffer,builder-util-runtime,call-bind,call-bind-apply-helpers,call-bound,cliui,concat-map,cross-spawn,dayjs,debug,dedent,define-data-property,dotenv,dunder-proto,electron-updater,emoji-regex,es-define-property,es-errors,es-object-atoms,escalade,for-each,foreground-child,fs-extra,function-bind,get-caller-file,get-east-asian-width,get-intrinsic,get-proto,glob,gopd,graceful-fs,has-property-descriptors,has-symbols,has-tostringtag,hasown,ieee754,inherits,is-callable,is-fullwidth-code-point,is-typed-array,isarray,isexe,jackspeak,js-yaml,jsonfile,lazy-val,lodash.escaperegexp,lodash.isequal,lru-cache,math-intrinsics,minimatch,minimist,minipass,mkdirp,ms,package-json-from-dist,path-is-absolute,path-key,path-scurry,possible-typed-array-names,reflect-metadata,safe-buffer,sax,semver,set-function-length,sha.js,shebang-command,shebang-regex,signal-exit,sql-highlight,sql.js,string-width,string-width-cjs,strip-ansi,strip-ansi-cjs,tiny-typed-emitter,to-buffer,tslib,typed-array-buffer,typeorm,universalify,untildify,uuid,which,which-typed-array,winreg,wrap-ansi,wrap-ansi-cjs,y18n,yallist,yargs,yargs-parser}/**/*",
|
"node_modules/**/*",
|
||||||
"node_modules/@isaacs/cliui/**/*",
|
|
||||||
"node_modules/@pkgjs/parseargs/**/*",
|
|
||||||
"node_modules/@sqltools/formatter/**/*",
|
|
||||||
"!node_modules/**/test/**/*",
|
"!node_modules/**/test/**/*",
|
||||||
"!node_modules/**/tests/**/*",
|
"!node_modules/**/tests/**/*",
|
||||||
"!node_modules/**/*.d.ts",
|
"!node_modules/**/*.d.ts",
|
||||||
|
|||||||
Binary file not shown.
@@ -17,77 +17,11 @@ import {
|
|||||||
import { serverMigrations } from '../migrations';
|
import { serverMigrations } from '../migrations';
|
||||||
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
||||||
|
|
||||||
function resolveDbFile(): string {
|
const DATA_DIR = resolveRuntimePath('data');
|
||||||
const envPath = process.env.DB_PATH;
|
const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite');
|
||||||
|
|
||||||
if (envPath) {
|
|
||||||
return path.resolve(envPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return path.join(resolveRuntimePath('data'), 'metoyou.sqlite');
|
|
||||||
}
|
|
||||||
|
|
||||||
const DB_FILE = resolveDbFile();
|
|
||||||
const DB_BACKUP = DB_FILE + '.bak';
|
|
||||||
const DATA_DIR = path.dirname(DB_FILE);
|
|
||||||
// SQLite files start with this 16-byte header string.
|
|
||||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
|
||||||
|
|
||||||
let applicationDataSource: DataSource | undefined;
|
let applicationDataSource: DataSource | undefined;
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when `data` looks like a valid SQLite file
|
|
||||||
* (correct header magic and at least one complete page).
|
|
||||||
*/
|
|
||||||
function isValidSqlite(data: Uint8Array): boolean {
|
|
||||||
if (data.length < 100)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
const header = Buffer.from(data.buffer, data.byteOffset, 16).toString('ascii');
|
|
||||||
|
|
||||||
return header === SQLITE_MAGIC;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Back up the current DB file so there is always a recovery point.
|
|
||||||
* If the main file is corrupted/empty but a valid backup exists,
|
|
||||||
* restore the backup before the server loads the database.
|
|
||||||
*/
|
|
||||||
function safeguardDbFile(): Uint8Array | undefined {
|
|
||||||
if (!fs.existsSync(DB_FILE))
|
|
||||||
return undefined;
|
|
||||||
|
|
||||||
const data = new Uint8Array(fs.readFileSync(DB_FILE));
|
|
||||||
|
|
||||||
if (isValidSqlite(data)) {
|
|
||||||
// Good file - rotate it into the backup slot.
|
|
||||||
fs.copyFileSync(DB_FILE, DB_BACKUP);
|
|
||||||
console.log('[DB] Backed up database to', DB_BACKUP);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The main file is corrupt or empty.
|
|
||||||
console.warn(`[DB] ${DB_FILE} appears corrupt (${data.length} bytes) - checking backup`);
|
|
||||||
|
|
||||||
if (fs.existsSync(DB_BACKUP)) {
|
|
||||||
const backup = new Uint8Array(fs.readFileSync(DB_BACKUP));
|
|
||||||
|
|
||||||
if (isValidSqlite(backup)) {
|
|
||||||
fs.copyFileSync(DB_BACKUP, DB_FILE);
|
|
||||||
console.warn('[DB] Restored database from backup', DB_BACKUP);
|
|
||||||
|
|
||||||
return backup;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('[DB] Backup is also invalid - starting with a fresh database');
|
|
||||||
} else {
|
|
||||||
console.error('[DB] No backup available - starting with a fresh database');
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
||||||
return {
|
return {
|
||||||
locateFile: (file) => {
|
locateFile: (file) => {
|
||||||
@@ -113,7 +47,10 @@ export async function initDatabase(): Promise<void> {
|
|||||||
if (!fs.existsSync(DATA_DIR))
|
if (!fs.existsSync(DATA_DIR))
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
const database = safeguardDbFile();
|
let database: Uint8Array | undefined;
|
||||||
|
|
||||||
|
if (fs.existsSync(DB_FILE))
|
||||||
|
database = fs.readFileSync(DB_FILE);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
applicationDataSource = new DataSource({
|
applicationDataSource = new DataSource({
|
||||||
@@ -157,7 +94,7 @@ export async function initDatabase(): Promise<void> {
|
|||||||
await applicationDataSource.runMigrations();
|
await applicationDataSource.runMigrations();
|
||||||
console.log('[DB] Migrations executed');
|
console.log('[DB] Migrations executed');
|
||||||
} else {
|
} else {
|
||||||
console.log('[DB] Synchronize mode - migrations skipped');
|
console.log('[DB] Synchronize mode — migrations skipped');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { resolveCertificateDirectory, resolveEnvFilePath } from './runtime-paths
|
|||||||
// Load .env from project root (one level up from server/)
|
// Load .env from project root (one level up from server/)
|
||||||
dotenv.config({ path: resolveEnvFilePath() });
|
dotenv.config({ path: resolveEnvFilePath() });
|
||||||
|
|
||||||
import { initDatabase, destroyDatabase } from './db/database';
|
import { initDatabase } from './db/database';
|
||||||
import { deleteStaleJoinRequests } from './cqrs';
|
import { deleteStaleJoinRequests } from './cqrs';
|
||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
import {
|
import {
|
||||||
@@ -119,26 +119,6 @@ async function bootstrap(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let shuttingDown = false;
|
|
||||||
|
|
||||||
async function gracefulShutdown(signal: string): Promise<void> {
|
|
||||||
if (shuttingDown) return;
|
|
||||||
shuttingDown = true;
|
|
||||||
|
|
||||||
console.log(`\n[Shutdown] ${signal} received — closing database…`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await destroyDatabase();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Shutdown] Error closing database:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
||||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
||||||
|
|
||||||
bootstrap().catch((err) => {
|
bootstrap().catch((err) => {
|
||||||
console.error('Failed to start server:', err);
|
console.error('Failed to start server:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ export class ServerChannels1000000000002 implements MigrationInterface {
|
|||||||
name = 'ServerChannels1000000000002';
|
name = 'ServerChannels1000000000002';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
const columns: { name: string }[] = await queryRunner.query(`PRAGMA table_info("servers")`);
|
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
|
||||||
const hasChannels = columns.some(c => c.name === 'channels');
|
|
||||||
if (!hasChannels) {
|
|
||||||
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user