58 Commits

Author SHA1 Message Date
Myx
a49e18b9f0 fix: recurriing network issue
All checks were successful
Queue Release Build / prepare (push) Successful in 18s
Deploy Web Apps / deploy (push) Successful in 6m32s
Queue Release Build / build-windows (push) Successful in 26m8s
Queue Release Build / build-linux (push) Successful in 40m18s
Queue Release Build / finalize (push) Successful in 42s
2026-04-30 04:04:34 +02:00
b1fe286be8 Merge pull request 'Plugins' (#14) from Plugins into main
All checks were successful
Queue Release Build / prepare (push) Successful in 20s
Deploy Web Apps / deploy (push) Successful in 8m30s
Queue Release Build / build-windows (push) Successful in 25m24s
Queue Release Build / build-linux (push) Successful in 41m32s
Queue Release Build / finalize (push) Successful in 30s
Reviewed-on: #14
2026-04-29 23:18:22 +00:00
Myx
0a714428f6 docs: improve doucmentation
improve doucmentation and fix small store changes
2026-04-30 01:16:48 +02:00
Myx
3f92e74350 feat: expose more apis 2026-04-29 23:39:09 +02:00
Myx
fa2cca6fa4 fix: improve plugins functionality with server management 2026-04-29 20:33:54 +02:00
Myx
b8f6d58d99 test: repair broken tests 2026-04-29 19:05:38 +02:00
Myx
e1ac1d1bc0 feat: server image 2026-04-29 18:54:08 +02:00
Myx
3d81c34159 feat: Add browser documentation 2026-04-29 17:15:01 +02:00
Myx
d261bac0ed feat: plugins v1.7 2026-04-29 15:24:56 +02:00
Myx
eabbc08896 feat: plugins v1.5 2026-04-29 01:14:30 +02:00
Myx
6920f93b41 feat: plugins v1 2026-04-29 01:14:14 +02:00
Myx
ec3802ade6 test: fix broken dm test
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 6m5s
Queue Release Build / build-windows (push) Successful in 17m1s
Queue Release Build / build-linux (push) Successful in 29m15s
Queue Release Build / finalize (push) Successful in 38s
2026-04-27 22:48:45 +02:00
Myx
66c6f34cd3 feat: Add game activity status (Experimental)
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
2026-04-27 11:02:34 +02:00
Myx
3858beb28e feat: Data management 2026-04-27 03:29:41 +02:00
Myx
1b91eacb5b feat: Theme studio v2 2026-04-27 03:02:13 +02:00
Myx
11c2588e45 feat: Add pm 2026-04-27 01:02:39 +02:00
Myx
bc2fa7de22 fix: multiple bug fixes
isolated users, db backup, weird disconnect issues for long voice sessions,
2026-04-26 22:54:13 +02:00
Myx
44588e8789 feat: Add TURN server support
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 5m35s
Queue Release Build / build-linux (push) Successful in 24m45s
Queue Release Build / build-windows (push) Successful in 13m52s
Queue Release Build / finalize (push) Successful in 23s
2026-04-18 21:27:04 +02:00
Myx
167c45ba8d test: Add 8 people voice tests 2026-04-18 14:24:11 +02:00
Myx
bd21568726 feat: Add user metadata changing display name and description with sync
All checks were successful
Queue Release Build / prepare (push) Successful in 28s
Deploy Web Apps / deploy (push) Successful in 5m2s
Queue Release Build / build-windows (push) Successful in 16m44s
Queue Release Build / build-linux (push) Successful in 27m12s
Queue Release Build / finalize (push) Successful in 22s
2026-04-17 22:55:50 +02:00
Myx
3ba8a2c9eb fix: Fix corrupt database, Add soundcloud and spotify embeds 2026-04-17 19:44:26 +02:00
Myx
28797a0141 perf: use lookup for chats
Some checks failed
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 15m8s
Queue Release Build / build-linux (push) Successful in 26m49s
Queue Release Build / build-windows (push) Failing after 12m6s
Queue Release Build / finalize (push) Has been skipped
2026-04-17 03:53:53 +02:00
Myx
17738ec484 feat: Add profile images 2026-04-17 03:06:44 +02:00
Myx
35b616fb77 refactor: Clean lint errors and organise files 2026-04-17 01:06:01 +02:00
Myx
2927a86fbb feat: Add user statuses and cards 2026-04-16 22:52:45 +02:00
Myx
b4ac0cdc92 fix: Windows audio mute fix 2026-04-16 19:07:44 +02:00
Myx
f3b56fb1cc fix: Db corruption fix
All checks were successful
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 10m0s
Queue Release Build / build-linux (push) Successful in 25m59s
Queue Release Build / build-windows (push) Successful in 21m44s
Queue Release Build / finalize (push) Successful in 18s
2026-04-13 02:23:09 +02:00
Myx
315820d487 ci: attempt to fix
All checks were successful
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 11m21s
Queue Release Build / build-linux (push) Successful in 25m31s
Queue Release Build / build-windows (push) Successful in 21m42s
Queue Release Build / finalize (push) Successful in 22s
2026-04-12 22:05:39 +02:00
Myx
878fd1c766 ci: Attempt to speed up build
Some checks failed
Queue Release Build / prepare (push) Successful in 50s
Deploy Web Apps / deploy (push) Failing after 7m46s
Queue Release Build / build-windows (push) Failing after 11m49s
Queue Release Build / build-linux (push) Successful in 34m1s
Queue Release Build / finalize (push) Has been skipped
2026-04-12 03:19:51 +02:00
Myx
391d9235f1 test: Add playwright main usage test
Some checks failed
Deploy Web Apps / deploy (push) Has been cancelled
Queue Release Build / prepare (push) Successful in 21s
Queue Release Build / build-linux (push) Successful in 27m44s
Queue Release Build / build-windows (push) Successful in 32m16s
Queue Release Build / finalize (push) Successful in 1m54s
2026-04-12 03:02:29 +02:00
Myx
f33440a827 test: Initate instructions 2026-04-11 15:38:16 +02:00
Myx
52912327ae refactor: stricter domain: voice-session 2026-04-11 15:12:54 +02:00
Myx
7a0664b3c4 refactor: stricter domain: voice-connection 2026-04-11 15:08:04 +02:00
Myx
cea3dccef1 refactor: stricter domain: theme 2026-04-11 15:01:39 +02:00
Myx
c8bb82feb5 refactor: stricter domain: server-directory 2026-04-11 14:50:38 +02:00
Myx
3fb5515c3a refactor: stricter domain: screen-share 2026-04-11 14:42:12 +02:00
Myx
a6bdac1a25 refactor: true facades 2026-04-11 14:35:02 +02:00
Myx
db7e683504 refactor: stricer domain: notifications 2026-04-11 14:23:50 +02:00
Myx
98ed8eeb68 refactor: stricter domain: chat 2026-04-11 14:01:19 +02:00
Myx
39b85e2e3a refactor: stricter domain: auth 2026-04-11 13:52:59 +02:00
Myx
58e338246f refactor: stricter domain: attachments 2026-04-11 13:39:27 +02:00
Myx
0b9a9f311e refactor: stricter domain: access-control 2026-04-11 13:31:25 +02:00
Myx
6800c73292 refactor: Cleaning rooms store 2026-04-11 13:07:46 +02:00
Myx
ef1182d46f fix: Broken voice states and connectivity drops 2026-04-11 12:32:22 +02:00
Myx
0865c2fe33 feat: Basic general context menu
All checks were successful
Queue Release Build / prepare (push) Successful in 14s
Deploy Web Apps / deploy (push) Successful in 14m39s
Queue Release Build / build-linux (push) Successful in 40m59s
Queue Release Build / build-windows (push) Successful in 28m59s
Queue Release Build / finalize (push) Successful in 1m58s
2026-04-04 05:38:05 +02:00
Myx
4a41de79d6 fix: debugger lagging from too many logs 2026-04-04 04:55:13 +02:00
Myx
84fa45985a feat: Add chat embeds v1
Youtube and Website metadata embeds
2026-04-04 04:47:04 +02:00
Myx
35352923a5 feat: Youtube embed support 2026-04-04 03:30:21 +02:00
Myx
b9df9c92f2 fix: links not getting recognised in chat 2026-04-04 03:14:25 +02:00
Myx
8674579b19 fix: leave and reconnect sound randomly playing, also fix leave sound when muting 2026-04-04 03:09:44 +02:00
Myx
de2d3300d4 fix: Fix users unable to see or hear each other in voice channels due to
stale server sockets, passive non-initiators, and race conditions
during peer connection setup.

Fix users unable to see or hear each other in voice channels due to
stale server sockets, passive non-initiators, and race conditions
during peer connection setup.

Server:
- Close stale WebSocket connections sharing the same oderId in
  handleIdentify instead of letting them linger up to 45s
- Make user_joined/user_left broadcasts identity-aware so duplicate
  sockets don't produce phantom join/leave events
- Include serverIds in user_left payload for multi-room presence
- Simplify findUserByOderId now that stale sockets are cleaned up

Client - signaling:
- Add fallback offer system with 1s timer for missed user_joined races
- Add non-initiator takeover after 5s when the initiator fails to send
  an offer (NON_INITIATOR_GIVE_UP_MS)
- Scope peerServerMap per signaling URL to prevent cross-server
  collisions
- Add socket identity guards on all signaling event handlers
- Replace canReusePeerConnection with hasActivePeerConnection and
  isPeerConnectionNegotiating with extended grace periods

Client - peer connections:
- Extract replaceUnusablePeer helper to deduplicate stale peer
  replacement in offer and ICE handlers
- Add stale connectionstatechange guard to ignore events from replaced
  RTCPeerConnection instances
- Use deterministic initiator election in peer recovery reconnects
- Track createdAt on PeerData for staleness detection

Client - presence:
- Add multi-room presence tracking via presenceServerIds on User
- Replace clearUsers + individual userJoined with syncServerPresence
  for atomic server roster updates
- Make userLeft handle partial server removal instead of full eviction

Documentation:
- Add server-side connection hygiene, non-initiator takeover, and stale
  peer replacement sections to the realtime README
2026-04-04 02:47:58 +02:00
Myx
ae0ee8fac7 Fix lint, make design more consistent, add license texts,
All checks were successful
Queue Release Build / prepare (push) Successful in 11s
Deploy Web Apps / deploy (push) Successful in 14m0s
Queue Release Build / build-linux (push) Successful in 35m41s
Queue Release Build / build-windows (push) Successful in 28m53s
Queue Release Build / finalize (push) Successful in 2m6s
2026-04-02 04:08:53 +02:00
Myx
37cac95b38 Add access control rework 2026-04-02 03:18:37 +02:00
Myx
314a26325f Database changes to make it better practise 2026-04-02 01:32:08 +02:00
Myx
5d7e045764 feat: Add chat seperator and restore last viewed chat on restart 2026-04-02 00:47:44 +02:00
Myx
bbb6deb0a2 feat: Theme engine
big changes
2026-04-02 00:08:38 +02:00
Myx
65b9419869 Rework design part 1 2026-04-01 19:31:00 +02:00
Myx
fed270d28d Fix issues with server navigation 2026-04-01 18:18:31 +02:00
610 changed files with 88659 additions and 6561 deletions

View File

@@ -0,0 +1,63 @@
---
name: caveman
description: >
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
wenyan-lite, wenyan-full, wenyan-ultra.
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
---
Respond terse like smart caveman. All technical substance stay. Only fluff die.
Default: **full**. Switch: `/caveman lite|full|ultra`.
## Rules
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
Pattern: `[thing] [action] [reason]. [next step].`
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
## Intensity
| Level | What change |
|-------|------------|
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
Example — "Why React component re-render?"
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
- wenyan-full: "物出新參照致重繪。useMemo .Wrap之。"
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
Example — "Explain database connection pooling."
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
## Auto-Clarity
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user confused. Resume caveman after clear part done.
Example — destructive op:
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
> ```sql
> DROP TABLE users;
> ```
> Caveman resume. Verify backup exist first.
## Boundaries
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.

View File

@@ -0,0 +1,283 @@
---
name: playwright-e2e
description: >
Write and run Playwright E2E tests for MetoYou (Angular chat + WebRTC voice/video app).
Handles multi-browser-context WebRTC voice testing, fake media devices, signaling validation,
audio channel verification, and UI state assertions. Use when: "E2E test", "Playwright",
"end-to-end", "browser test", "test voice", "test call", "test WebRTC", "test chat",
"test login", "test video", "test screen share", "integration test", "multi-client test".
---
# Playwright E2E Testing — MetoYou
## Step 1 — Check Project Setup
Before writing any test, verify the Playwright infrastructure exists:
```
e2e/ # Test root (lives at repo root)
├── playwright.config.ts # Config
├── fixtures/ # Custom fixtures (multi-client, auth, etc.)
├── pages/ # Page Object Models
├── tests/ # Test specs
│ ├── auth/
│ ├── chat/
│ ├── voice/
│ └── settings/
└── helpers/ # Shared utilities (WebRTC introspection, etc.)
```
If missing, scaffold it. See [reference/project-setup.md](./reference/project-setup.md).
## Step 2 — Identify Test Category
| Request | Category | Key Patterns |
| ------------------------------- | ---------------- | ---------------------------------------------- |
| Login, register, invite | **Auth** | Single browser context, form interaction |
| Send message, rooms, chat UI | **Chat** | May need 2 clients for real-time sync |
| Voice call, mute, deafen, audio | **Voice/WebRTC** | Multi-client, fake media, WebRTC introspection |
| Camera, video tiles | **Video** | Multi-client, fake video, stream validation |
| Screen share | **Screen Share** | Multi-client, display media mocking |
| Settings, themes | **Settings** | Single client, preference persistence |
For **Voice/WebRTC** and **Multi-client** tests, read [reference/multi-client-webrtc.md](./reference/multi-client-webrtc.md) immediately.
## Step 3 — Core Conventions
### Config Essentials
```typescript
// e2e/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 60_000, // WebRTC needs longer timeouts
expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 0,
workers: 1, // Sequential — shared server state
reporter: [['html'], ['list']],
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
permissions: ['microphone', 'camera']
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream'
// Feed a specific audio file as fake mic input:
// '--use-file-for-fake-audio-capture=/path/to/audio.wav',
]
}
}
}
],
webServer: [
{
command: 'cd server && npm run dev',
port: 3001,
reuseExistingServer: !process.env.CI,
timeout: 30_000
},
{
command: 'cd toju-app && npx ng serve',
port: 4200,
reuseExistingServer: !process.env.CI,
timeout: 60_000
}
]
});
```
### Selector Strategy
Use in this order — stop at the first that works:
1. `getByRole('button', { name: 'Mute' })` — accessible, resilient
2. `getByLabel('Email')` — form fields
3. `getByPlaceholder('Enter email')` — when label missing
4. `getByText('Welcome')` — visible text
5. `getByTestId('voice-controls')` — last resort, needs `data-testid`
6. `locator('app-voice-controls')` — Angular component selectors (acceptable in this project)
Angular component selectors (`app-*`) are stable in this project and acceptable as locators when semantic selectors are not feasible.
### Assertions — Always Web-First
```typescript
// ✅ Auto-retries until timeout
await expect(page.getByRole('heading')).toBeVisible();
await expect(page.getByRole('alert')).toHaveText('Saved');
await expect(page).toHaveURL(/\/room\//);
// ❌ No auto-retry — races with DOM
const text = await page.textContent('.msg');
expect(text).toBe('Saved');
```
### Anti-Patterns
| ❌ Don't | ✅ Do | Why |
| ------------------------------ | --------------------------------------------------- | ------------------------- |
| `page.waitForTimeout(3000)` | `await expect(locator).toBeVisible()` | Hard waits are flaky |
| `expect(await el.isVisible())` | `await expect(el).toBeVisible()` | No auto-retry |
| `page.$('.btn')` | `page.getByRole('button')` | Fragile selector |
| `page.click('.submit')` | `page.getByRole('button', {name:'Submit'}).click()` | Not accessible |
| Shared state between tests | `test.beforeEach` for setup | Tests must be independent |
| `try/catch` around assertions | Let Playwright handle retries | Swallows real failures |
### Test Structure
```typescript
import { test, expect } from '../fixtures/base';
test.describe('Feature Name', () => {
test('should do specific thing', async ({ page }) => {
await test.step('Navigate to page', async () => {
await page.goto('/login');
});
await test.step('Fill form', async () => {
await page.getByLabel('Email').fill('test@example.com');
});
await test.step('Verify result', async () => {
await expect(page).toHaveURL(/\/search/);
});
});
});
```
Use `test.step()` for readability in complex flows.
## Step 4 — Page Object Models
Use POM for any test file with more than 2 tests. Match the app's route structure:
```typescript
// e2e/pages/login.page.ts
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: /sign in|log in/i });
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
```
**Key pages to model (match `app.routes.ts`):**
| Route | Page Object | Component |
| ------------------- | ------------------ | ----------------------- |
| `/login` | `LoginPage` | `LoginComponent` |
| `/register` | `RegisterPage` | `RegisterComponent` |
| `/search` | `ServerSearchPage` | `ServerSearchComponent` |
| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` |
| `/settings` | `SettingsPage` | `SettingsComponent` |
| `/invite/:inviteId` | `InvitePage` | `InviteComponent` |
## Step 5 — MetoYou App Architecture Context
The agent writing tests MUST understand these domain boundaries:
### Voice/WebRTC Stack
| Layer | What It Does | Test Relevance |
| ----------------------- | ----------------------------------------------------- | -------------------------------- |
| `VoiceConnectionFacade` | High-level voice API (connect/disconnect/mute/deafen) | State signals to assert against |
| `VoiceSessionFacade` | Session lifecycle, workspace layout | UI mode changes |
| `VoiceActivityService` | Speaking detection (RMS threshold 0.015) | `isSpeaking()` signal validation |
| `VoicePlaybackService` | Per-peer GainNode (0200% volume) | Volume level assertions |
| `PeerConnectionManager` | RTCPeerConnection lifecycle | Connection state introspection |
| `MediaManager` | getUserMedia, mute, gain chain | Track state validation |
| `SignalingManager` | WebSocket per signal URL | Connection establishment |
### Voice UI Components
| Component | Selector | Contains |
| ----------------------------------- | --------------------------------- | ------------------------------------------- |
| `VoiceWorkspaceComponent` | `app-voice-workspace` | Stream tiles, layout |
| `VoiceControlsComponent` | `app-voice-controls` | Mute, camera, screen share, hang-up buttons |
| `FloatingVoiceControlsComponent` | `app-floating-voice-controls` | Floating variant of controls |
| `VoiceWorkspaceStreamTileComponent` | `app-voice-workspace-stream-tile` | Per-peer audio/video tile |
### Voice UI Icons (Lucide)
| Icon | Meaning |
| ------------------------------------ | -------------------- |
| `lucideMic` / `lucideMicOff` | Mute toggle |
| `lucideVideo` / `lucideVideoOff` | Camera toggle |
| `lucideMonitor` / `lucideMonitorOff` | Screen share toggle |
| `lucidePhoneOff` | Hang up / disconnect |
| `lucideHeadphones` | Deafen state |
| `lucideVolume2` / `lucideVolumeX` | Volume indicator |
### Server & Signaling
- **Signaling server**: Port `3001` (HTTP by default, HTTPS if `SSL=true`)
- **Angular dev server**: Port `4200`
- **WebSocket signaling**: Upgrades on same port as server
- **Protocol**: `identify``server_users` → SDP offer/answer → ICE candidates
- **PING/PONG**: Every 30s, 45s timeout
## Step 6 — Validation Workflow
After generating any test:
```
1. npx playwright install --with-deps chromium # First time only
2. npx playwright test --project=chromium # Run tests
3. npx playwright test --ui # Interactive debug
4. npx playwright show-report # HTML report
```
If the test involves WebRTC, always verify:
- Fake media flags are set in config
- Timeouts are sufficient (60s+ for connection establishment)
- `workers: 1` if tests share server state
- Browser permissions granted for microphone/camera
## Quick Reference — Commands
```bash
npx playwright test # Run all
npx playwright test --ui # Interactive UI
npx playwright test --debug # Step-through debugger
npx playwright test tests/voice/ # Voice tests only
npx playwright test --project=chromium # Single browser
npx playwright test -g "voice connects" # By test name
npx playwright show-report # HTML report
npx playwright codegen http://localhost:4200 # Record test
```
## Reference Files
| File | When to Read |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------ |
| [reference/multi-client-webrtc.md](./reference/multi-client-webrtc.md) | Voice/video/WebRTC tests, multi-browser contexts, audio validation |
| [reference/project-setup.md](./reference/project-setup.md) | First-time scaffold, dependency installation, config creation |

View File

@@ -0,0 +1,536 @@
# Multi-Client WebRTC Testing
This reference covers the hardest E2E testing scenario in MetoYou: verifying that voice/video connections actually work between multiple clients.
## Core Concept: Multiple Browser Contexts
Playwright can create multiple **independent** browser contexts within a single test. Each context is an isolated session (separate cookies, storage, WebRTC state). This is how we simulate multiple users.
```typescript
import { test, expect, chromium } from '@playwright/test';
test('two users can voice chat', async () => {
const browser = await chromium.launch({
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
'--use-file-for-fake-audio-capture=e2e/fixtures/test-tone-440hz.wav',
],
});
const contextA = await browser.newContext({
permissions: ['microphone', 'camera'],
});
const contextB = await browser.newContext({
permissions: ['microphone', 'camera'],
});
const alice = await contextA.newPage();
const bob = await contextB.newPage();
// ... test logic with alice and bob ...
await contextA.close();
await contextB.close();
await browser.close();
});
```
## Custom Fixture: Multi-Client
Create a reusable fixture for multi-client tests:
```typescript
// e2e/fixtures/multi-client.ts
import { test as base, chromium, type Page, type BrowserContext, type Browser } from '@playwright/test';
type Client = {
page: Page;
context: BrowserContext;
};
type MultiClientFixture = {
createClient: () => Promise<Client>;
browser: Browser;
};
export const test = base.extend<MultiClientFixture>({
browser: async ({}, use) => {
const browser = await chromium.launch({
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
],
});
await use(browser);
await browser.close();
},
createClient: async ({ browser }, use) => {
const clients: Client[] = [];
const factory = async (): Promise<Client> => {
const context = await browser.newContext({
permissions: ['microphone', 'camera'],
baseURL: 'http://localhost:4200',
});
const page = await context.newPage();
clients.push({ page, context });
return { page, context };
};
await use(factory);
// Cleanup
for (const client of clients) {
await client.context.close();
}
},
});
export { expect } from '@playwright/test';
```
**Usage:**
```typescript
import { test, expect } from '../fixtures/multi-client';
test('voice call connects between two users', async ({ createClient }) => {
const alice = await createClient();
const bob = await createClient();
// Login both users
await alice.page.goto('/login');
await bob.page.goto('/login');
// ... login flows ...
});
```
## Fake Media Devices
Chromium's fake device flags are essential for headless WebRTC testing:
| Flag | Purpose |
|------|---------|
| `--use-fake-device-for-media-stream` | Provides fake mic/camera devices (no real hardware needed) |
| `--use-fake-ui-for-media-stream` | Auto-grants device permission prompts |
| `--use-file-for-fake-audio-capture=<path>` | Feeds a WAV/WebM file as fake mic input |
| `--use-file-for-fake-video-capture=<path>` | Feeds a Y4M/MJPEG file as fake video input |
### Test Audio File
Create a 440Hz sine wave test tone for audio verification:
```bash
# Generate a 5-second 440Hz mono WAV test tone
ffmpeg -f lavfi -i "sine=frequency=440:duration=5" -ar 48000 -ac 1 e2e/fixtures/test-tone-440hz.wav
```
Or use Playwright's built-in fake device which generates a test pattern (beep) automatically when `--use-fake-device-for-media-stream` is set without specifying a file.
## WebRTC Connection Introspection
### Checking RTCPeerConnection State
Inject JavaScript to inspect WebRTC internals via `page.evaluate()`:
```typescript
// e2e/helpers/webrtc-helpers.ts
/**
* Get all RTCPeerConnection instances and their states.
* Requires the app to expose connections (or use a monkey-patch approach).
*/
export async function getPeerConnectionStates(page: Page): Promise<Array<{
connectionState: string;
iceConnectionState: string;
signalingState: string;
}>> {
return page.evaluate(() => {
// Approach 1: Use chrome://webrtc-internals equivalent
// Approach 2: Monkey-patch RTCPeerConnection (install before app loads)
// Approach 3: Expose from app (preferred for this project)
// MetoYou exposes via Angular — access the injector
const appRef = (window as any).ng?.getComponent(document.querySelector('app-root'));
// This needs adaptation based on actual exposure method
// Fallback: Use performance.getEntriesByType or WebRTC stats
return [];
});
}
/**
* Wait for at least one peer connection to reach 'connected' state.
*/
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
await page.waitForFunction(() => {
// Check if any RTCPeerConnection reached 'connected'
return (window as any).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false;
}, { timeout });
}
/**
* Get WebRTC stats for audio tracks (inbound/outbound).
*/
export async function getAudioStats(page: Page): Promise<{
outbound: { bytesSent: number; packetsSent: number } | null;
inbound: { bytesReceived: number; packetsReceived: number } | null;
}> {
return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) return { outbound: null, inbound: null };
const pc = connections[0];
const stats = await pc.getStats();
let outbound: any = null;
let inbound: any = null;
stats.forEach((report) => {
if (report.type === 'outbound-rtp' && report.kind === 'audio') {
outbound = { bytesSent: report.bytesSent, packetsSent: report.packetsSent };
}
if (report.type === 'inbound-rtp' && report.kind === 'audio') {
inbound = { bytesReceived: report.bytesReceived, packetsReceived: report.packetsReceived };
}
});
return { outbound, inbound };
});
}
```
### RTCPeerConnection Monkey-Patch
To track all peer connections created by the app, inject a monkey-patch **before** navigation:
```typescript
/**
* Install RTCPeerConnection tracking on a page BEFORE navigating.
* Call this immediately after page creation, before any goto().
*/
export async function installWebRTCTracking(page: Page): Promise<void> {
await page.addInitScript(() => {
const connections: RTCPeerConnection[] = [];
(window as any).__rtcConnections = connections;
const OriginalRTCPeerConnection = window.RTCPeerConnection;
(window as any).RTCPeerConnection = function (...args: any[]) {
const pc = new OriginalRTCPeerConnection(...args);
connections.push(pc);
pc.addEventListener('connectionstatechange', () => {
(window as any).__lastRtcState = pc.connectionState;
});
// Track remote streams
pc.addEventListener('track', (event) => {
if (!((window as any).__rtcRemoteTracks)) {
(window as any).__rtcRemoteTracks = [];
}
(window as any).__rtcRemoteTracks.push({
kind: event.track.kind,
id: event.track.id,
readyState: event.track.readyState,
});
});
return pc;
} as any;
// Preserve prototype chain
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
});
}
```
## Voice Call Test Pattern — Full Example
This is the canonical pattern for testing that voice actually connects between two clients:
```typescript
import { test, expect } from '../fixtures/multi-client';
import { installWebRTCTracking, getAudioStats } from '../helpers/webrtc-helpers';
test.describe('Voice Call', () => {
test('two users connect voice and exchange audio', async ({ createClient }) => {
const alice = await createClient();
const bob = await createClient();
// Install WebRTC tracking BEFORE navigation
await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page);
await test.step('Both users log in', async () => {
// Login Alice
await alice.page.goto('/login');
await alice.page.getByLabel('Email').fill('alice@test.com');
await alice.page.getByLabel('Password').fill('password123');
await alice.page.getByRole('button', { name: /sign in/i }).click();
await expect(alice.page).toHaveURL(/\/search/);
// Login Bob
await bob.page.goto('/login');
await bob.page.getByLabel('Email').fill('bob@test.com');
await bob.page.getByLabel('Password').fill('password123');
await bob.page.getByRole('button', { name: /sign in/i }).click();
await expect(bob.page).toHaveURL(/\/search/);
});
await test.step('Both users join the same room', async () => {
// Navigate to a shared room (adapt URL to actual room ID)
const roomUrl = '/room/test-room-id';
await alice.page.goto(roomUrl);
await bob.page.goto(roomUrl);
// Verify both are in the room
await expect(alice.page.locator('app-chat-room')).toBeVisible();
await expect(bob.page.locator('app-chat-room')).toBeVisible();
});
await test.step('Alice starts voice', async () => {
// Click the voice/call join button (adapt selector to actual UI)
await alice.page.getByRole('button', { name: /join voice|connect/i }).click();
// Voice workspace should appear
await expect(alice.page.locator('app-voice-workspace')).toBeVisible();
// Voice controls should be visible
await expect(alice.page.locator('app-voice-controls')).toBeVisible();
});
await test.step('Bob joins voice', async () => {
await bob.page.getByRole('button', { name: /join voice|connect/i }).click();
await expect(bob.page.locator('app-voice-workspace')).toBeVisible();
});
await test.step('WebRTC connection establishes', async () => {
// Wait for peer connection to reach 'connected' on both sides
await alice.page.waitForFunction(
() => (window as any).__rtcConnections?.some(
(pc: any) => pc.connectionState === 'connected'
),
{ timeout: 30_000 }
);
await bob.page.waitForFunction(
() => (window as any).__rtcConnections?.some(
(pc: any) => pc.connectionState === 'connected'
),
{ timeout: 30_000 }
);
});
await test.step('Audio is flowing in both directions', async () => {
// Wait a moment for audio stats to accumulate
await alice.page.waitForTimeout(3_000); // Acceptable here: waiting for stats accumulation
// Check Alice is sending audio
const aliceStats = await getAudioStats(alice.page);
expect(aliceStats.outbound).not.toBeNull();
expect(aliceStats.outbound!.bytesSent).toBeGreaterThan(0);
expect(aliceStats.outbound!.packetsSent).toBeGreaterThan(0);
// Check Bob is sending audio
const bobStats = await getAudioStats(bob.page);
expect(bobStats.outbound).not.toBeNull();
expect(bobStats.outbound!.bytesSent).toBeGreaterThan(0);
// Check Alice receives Bob's audio
expect(aliceStats.inbound).not.toBeNull();
expect(aliceStats.inbound!.bytesReceived).toBeGreaterThan(0);
// Check Bob receives Alice's audio
expect(bobStats.inbound).not.toBeNull();
expect(bobStats.inbound!.bytesReceived).toBeGreaterThan(0);
});
await test.step('Voice UI states are correct', async () => {
// Both should see stream tiles for each other
await expect(alice.page.locator('app-voice-workspace-stream-tile')).toHaveCount(2);
await expect(bob.page.locator('app-voice-workspace-stream-tile')).toHaveCount(2);
// Mute button should be visible and in unmuted state
// (lucideMic icon visible, lucideMicOff NOT visible)
await expect(alice.page.locator('app-voice-controls')).toBeVisible();
});
await test.step('Mute toggles correctly', async () => {
// Alice mutes
await alice.page.getByRole('button', { name: /mute/i }).click();
// Alice's local UI shows muted state
// Bob should see Alice as muted (mute indicator on her tile)
// Verify no audio being sent from Alice after mute
const preStats = await getAudioStats(alice.page);
await alice.page.waitForTimeout(2_000);
const postStats = await getAudioStats(alice.page);
// Bytes sent should not increase (or increase minimally — comfort noise)
// The exact assertion depends on whether mute stops the track or sends silence
});
await test.step('Alice hangs up', async () => {
await alice.page.getByRole('button', { name: /hang up|disconnect|leave/i }).click();
// Voice workspace should disappear for Alice
await expect(alice.page.locator('app-voice-workspace')).not.toBeVisible();
// Bob should see Alice's tile disappear
await expect(bob.page.locator('app-voice-workspace-stream-tile')).toHaveCount(1);
});
});
});
```
## Verifying Audio Is Actually Received
Beyond checking `bytesReceived > 0`, you can verify actual audio energy:
```typescript
/**
* Check if audio energy is present on a received stream.
* Uses Web Audio AnalyserNode — same approach as MetoYou's VoiceActivityService.
*/
export async function hasAudioEnergy(page: Page): Promise<boolean> {
return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[];
if (!connections?.length) return false;
for (const pc of connections) {
const receivers = pc.getReceivers();
for (const receiver of receivers) {
if (receiver.track.kind !== 'audio' || receiver.track.readyState !== 'live') continue;
const audioCtx = new AudioContext();
const source = audioCtx.createMediaStreamSource(new MediaStream([receiver.track]));
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
// Sample over 500ms
const dataArray = new Float32Array(analyser.frequencyBinCount);
await new Promise(resolve => setTimeout(resolve, 500));
analyser.getFloatTimeDomainData(dataArray);
// Calculate RMS (same as VoiceActivityService)
let sum = 0;
for (const sample of dataArray) {
sum += sample * sample;
}
const rms = Math.sqrt(sum / dataArray.length);
audioCtx.close();
// MetoYou uses threshold 0.015 — use lower threshold for test
if (rms > 0.005) return true;
}
}
return false;
});
}
```
## Testing Speaker/Playback Volume
MetoYou uses per-peer GainNode chains (0200%). To verify:
```typescript
await test.step('Bob adjusts Alice volume to 50%', async () => {
// Interact with volume slider in stream tile
// (adapt selector to actual volume control UI)
const volumeSlider = bob.page.locator('app-voice-workspace-stream-tile')
.filter({ hasText: 'Alice' })
.getByRole('slider');
await volumeSlider.fill('50');
// Verify the gain was set (check via WebRTC or app state)
const gain = await bob.page.evaluate(() => {
// Access VoicePlaybackService through Angular DI if exposed
// Or check audio element volume
const audioElements = document.querySelectorAll('audio');
return audioElements[0]?.volume;
});
});
```
## Testing Screen Share
Screen share requires `getDisplayMedia()` which cannot be auto-granted. Options:
1. **Mock at the browser level** — use `page.addInitScript()` to replace `getDisplayMedia` with a fake stream
2. **Use Chromium flags**`--auto-select-desktop-capture-source=Entire screen`
```typescript
// Mock getDisplayMedia before navigation
await page.addInitScript(() => {
navigator.mediaDevices.getDisplayMedia = async () => {
// Create a simple canvas stream as fake screen share
const canvas = document.createElement('canvas');
canvas.width = 1280;
canvas.height = 720;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#4a90d9';
ctx.fillRect(0, 0, 1280, 720);
ctx.fillStyle = 'white';
ctx.font = '48px sans-serif';
ctx.fillText('Fake Screen Share', 400, 380);
return canvas.captureStream(30);
};
});
```
## Debugging Tips
### Trace Viewer
When a WebRTC test fails, the trace captures network requests, console logs, and screenshots:
```bash
npx playwright show-trace test-results/trace.zip
```
### Console Log Forwarding
Forward browser console to Node for real-time debugging:
```typescript
page.on('console', msg => console.log(`[${name}]`, msg.text()));
page.on('pageerror', err => console.error(`[${name}] PAGE ERROR:`, err.message));
```
### WebRTC Internals
Chromium exposes WebRTC stats at `chrome://webrtc-internals/`. In Playwright, access the same data via:
```typescript
const stats = await page.evaluate(async () => {
const pcs = (window as any).__rtcConnections;
return Promise.all(pcs.map(async (pc: any) => {
const stats = await pc.getStats();
const result: any[] = [];
stats.forEach((report: any) => result.push(report));
return result;
}));
});
```
## Timeout Guidelines
| Operation | Recommended Timeout |
|-----------|-------------------|
| Page navigation | 10s (default) |
| Login flow | 15s |
| WebRTC connection establishment | 30s |
| ICE negotiation (TURN fallback) | 45s |
| Audio stats accumulation | 35s after connection |
| Full voice test (end-to-end) | 90s |
| Screen share setup | 15s |
## Parallelism Warning
WebRTC multi-client tests **must** run with `workers: 1` (sequential) because:
- All clients share the same signaling server instance
- Server state (rooms, users) is mutable
- ICE candidates reference `localhost` — port conflicts possible with parallel launches
If you need parallelism, use separate server instances with different ports per worker.

View File

@@ -0,0 +1,175 @@
# Project Setup — Playwright E2E for MetoYou
Before creating any tests or changing them read AGENTS.md for a understanding how the application works.
## First-Time Scaffold
### 1. Install Dependencies
From the **repository root**:
```bash
npm install -D @playwright/test
npx playwright install --with-deps chromium
```
Only Chromium is needed — WebRTC fake media flags are Chromium-only. Add Firefox/WebKit later if needed for non-WebRTC tests.
### 2. Create Directory Structure
```bash
mkdir -p e2e/{tests/{auth,chat,voice,settings},fixtures,pages,helpers}
```
### 3. Create Config
Create `e2e/playwright.config.ts`:
```typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 60_000,
expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [['html', { outputFolder: '../test-results/html-report' }], ['list']],
outputDir: '../test-results/artifacts',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
permissions: ['microphone', 'camera'],
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
],
},
},
},
],
webServer: [
{
command: 'cd ../server && npm run dev',
port: 3001,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
{
command: 'cd ../toju-app && npx ng serve',
port: 4200,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
],
});
```
### 4. Create Base Fixture
Create `e2e/fixtures/base.ts`:
```typescript
import { test as base } from '@playwright/test';
export const test = base.extend({
// Add common fixtures here as the test suite grows
// Examples: authenticated page, test data seeding, etc.
});
export { expect } from '@playwright/test';
```
### 5. Create Multi-Client Fixture
Create `e2e/fixtures/multi-client.ts` — see [multi-client-webrtc.md](./multi-client-webrtc.md) for the full fixture code.
### 6. Create WebRTC Helpers
Create `e2e/helpers/webrtc-helpers.ts` — see [multi-client-webrtc.md](./multi-client-webrtc.md) for helper functions.
### 7. Create Isolated Test Server Launcher
The app requires a signal server. Tests use an isolated instance with its own temporary database so test data never pollutes the dev environment.
Create `e2e/helpers/start-test-server.js` — a Node.js script that:
1. Creates a temp directory under the OS tmpdir
2. Writes a `data/variables.json` with `serverPort: 3099`, `serverProtocol: "http"`
3. Spawns `ts-node server/src/index.ts` with `cwd` set to the temp dir
4. Cleans up the temp dir on exit
The server's `getRuntimeBaseDir()` returns `process.cwd()`, so setting cwd to the temp dir makes the database go to `<tmpdir>/data/metoyou.sqlite`. Module resolution (`require`/`import`) uses `__dirname`, so server source and `node_modules` resolve correctly from the real `server/` directory.
Playwright's `webServer` config calls this script and waits for port 3099 to be ready.
### 8. Create Test Endpoint Seeder
The Angular app reads signal endpoints from `localStorage['metoyou_server_endpoints']`. By default it falls back to production URLs in `environment.ts`. For tests, seed localStorage with a single endpoint pointing at `http://localhost:3099`.
Create `e2e/helpers/seed-test-endpoint.ts` — called automatically by the multi-client fixture after creating each browser context. The flow is:
1. Navigate to `/` (establishes the origin for localStorage)
2. Set `metoyou_server_endpoints` to `[{ id: 'e2e-test-server', url: 'http://localhost:3099', ... }]`
3. Set `metoyou_removed_default_server_keys` to suppress production endpoints
4. Reload the page so the app picks up the test endpoint
### 9. Add npm Scripts
Add to root `package.json`:
```json
{
"scripts": {
"test:e2e": "cd e2e && npx playwright test",
"test:e2e:ui": "cd e2e && npx playwright test --ui",
"test:e2e:debug": "cd e2e && npx playwright test --debug",
"test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report"
}
}
```
### 8. Update .gitignore
Add to `.gitignore`:
```
# Playwright
test-results/
e2e/playwright-report/
```
### 9. Generate Test Audio Fixture (Optional)
For voice tests with controlled audio input:
```bash
# Requires ffmpeg
ffmpeg -f lavfi -i "sine=frequency=440:duration=5" -ar 48000 -ac 1 e2e/fixtures/test-tone-440hz.wav
```
## Existing Dev Stack Integration
The tests assume the standard MetoYou dev stack:
- **Signaling server** at `http://localhost:3001` (via `server/npm run dev`)
- **Angular dev server** at `http://localhost:4200` (via `toju-app/npx ng serve`)
The `webServer` config in `playwright.config.ts` starts these automatically if not already running. When running `npm run dev` (full Electron stack) separately, tests will reuse the existing servers.
## Test Data Requirements
E2E tests need user accounts to log in with. Options:
1. **Seed via API** — create users in `test.beforeAll` via the server REST API
2. **Pre-seeded database** — maintain a test SQLite database with known accounts
3. **Register in test** — use the `/register` flow as a setup step (slower but self-contained)
Recommended: Option 3 for initial setup, migrate to Option 1 as the test suite grows.

View File

@@ -17,6 +17,13 @@ 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

View File

@@ -48,17 +48,30 @@ 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', 'docs-site/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
cd ../docs-site && 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: >
@@ -71,6 +84,7 @@ jobs:
cd toju-app cd toju-app
npx ng build --configuration production --base-href='./' npx ng build --configuration production --base-href='./'
cd .. cd ..
npm run build:docs
npx --package typescript tsc -p tsconfig.electron.json npx --package typescript tsc -p tsconfig.electron.json
cd server cd server
node ../tools/sync-server-build-version.js node ../tools/sync-server-build-version.js
@@ -108,12 +122,29 @@ 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', 'docs-site/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
run: | run: |
npm ci npm ci
npm ci --prefix server npm ci --prefix server
npm ci --prefix docs-site
- name: Set CI release version - name: Set CI release version
run: > run: >
@@ -126,6 +157,7 @@ jobs:
Push-Location "toju-app" Push-Location "toju-app"
npx ng build --configuration production --base-href='./' npx ng build --configuration production --base-href='./'
Pop-Location Pop-Location
npm run build:docs
npx --package typescript tsc -p tsconfig.electron.json npx --package typescript tsc -p tsconfig.electron.json
Push-Location server Push-Location server
node ../tools/sync-server-build-version.js node ../tools/sync-server-build-version.js
@@ -166,6 +198,7 @@ jobs:
Copy-Item -Path (Join-Path $projectRoot 'package.json') -Destination (Join-Path $electronBuilderWorkspace 'package.json') -Force Copy-Item -Path (Join-Path $projectRoot 'package.json') -Destination (Join-Path $electronBuilderWorkspace 'package.json') -Force
Copy-Item -Path (Join-Path $projectRoot 'package-lock.json') -Destination (Join-Path $electronBuilderWorkspace 'package-lock.json') -Force Copy-Item -Path (Join-Path $projectRoot 'package-lock.json') -Destination (Join-Path $electronBuilderWorkspace 'package-lock.json') -Force
Invoke-RoboCopy (Join-Path $projectRoot 'dist') (Join-Path $electronBuilderWorkspace 'dist') Invoke-RoboCopy (Join-Path $projectRoot 'dist') (Join-Path $electronBuilderWorkspace 'dist')
Invoke-RoboCopy (Join-Path $projectRoot 'docs-site/build') (Join-Path $electronBuilderWorkspace 'docs-site/build')
Invoke-RoboCopy (Join-Path $projectRoot 'images') (Join-Path $electronBuilderWorkspace 'images') Invoke-RoboCopy (Join-Path $projectRoot 'images') (Join-Path $electronBuilderWorkspace 'images')
Invoke-RoboCopy (Join-Path $projectRoot 'node_modules') (Join-Path $electronBuilderWorkspace 'node_modules') Invoke-RoboCopy (Join-Path $projectRoot 'node_modules') (Join-Path $electronBuilderWorkspace 'node_modules')
@@ -217,9 +250,6 @@ 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 }}

12
.gitignore vendored
View File

@@ -16,6 +16,7 @@ yarn-error.log
dist-electron dist-electron
node_modules/* node_modules/*
*server/node_modules/* *server/node_modules/*
/docs-site/node_modules/
.angular .angular
# IDEs and editors # IDEs and editors
.idea/ .idea/
@@ -39,11 +40,17 @@ node_modules/*
.sass-cache/ .sass-cache/
/connect.lock /connect.lock
/coverage /coverage
/docs-site/.docusaurus/
/docs-site/build/
/libpeerconnection.log /libpeerconnection.log
testem.log testem.log
/typings /typings
__screenshots__/ __screenshots__/
# Playwright
test-results/
e2e/playwright-report/
# System files # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@@ -56,3 +63,8 @@ dist-server/*
AGENTS.md AGENTS.md
doc/** doc/**
metoyou.sqlite*
metoyou.sqlite
vitest/

169
README.md
View File

@@ -1,119 +1,92 @@
<img src="./images/icon.png" width="100" height="100"> <img src="./images/icon.png" width="100" height="100">
# MetoYou / Toju
# Toju / Zoracord MetoYou is a desktop-first chat stack managed as an npm monorepo. The repository contains the Angular 21 product client, the Electron desktop shell, the Node/TypeScript signaling server, the Playwright E2E suite, and the Angular 19 marketing website.
Desktop chat app with four parts: ## Packages
- `src/` Angular client | Path | Purpose | Docs |
- `electron/` desktop shell, IPC, and local database | --- | --- | --- |
- `server/` directory server, join request API, and websocket events | `toju-app/` | Angular 21 product client | [toju-app/README.md](toju-app/README.md) |
- `website/` Toju website served at toju.app | `electron/` | Electron main process, preload bridge, IPC, and desktop integrations | [electron/README.md](electron/README.md) |
| `server/` | Signaling server, server-directory API, and websocket runtime | [server/README.md](server/README.md) |
| `e2e/` | Playwright end-to-end coverage for the product client | [e2e/README.md](e2e/README.md) |
| `website/` | Angular 19 marketing site served separately from the product client | [website/README.md](website/README.md) |
| `docs-site/` | Docusaurus app and plugin documentation served by the Electron Local API | [docs-site/docs/intro.md](docs-site/docs/intro.md) |
## Install ## Install
1. Run `npm install` 1. Run `npm install` from the repository root.
2. Run `cd server && npm install` 2. Run `cd server && npm install` for the server package.
3. Copy `.env.example` to `.env` 3. If you need to work on the marketing site, run `cd website && npm install`.
4. If you need to work on the Docusaurus docs, run `cd docs-site && npm install`.
5. Copy `.env.example` to `.env`.
## Config ## Configuration
Root `.env`: - Root `.env` controls local SSL with `SSL=true|false`.
- The server also honors an optional `PORT` environment override at runtime.
- When `SSL=true`, run `./generate-cert.sh` once or let `./dev.sh` generate local certificates on first launch.
- `server/data/variables.json` stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`. The server normalizes this file on startup.
- When `serverProtocol` is `https`, the certificates in `.certs/` must exist and match the configured host or IP.
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode ## Main Commands
- `PORT=3001` changes the server port in local development and overrides the server app setting
If `SSL=true`, run `./generate-cert.sh` once. - `npm run dev` starts the full desktop stack: server, product client, and Electron.
- `npm run start` starts only the Angular product client in `toju-app/`.
- `npm run electron:dev` starts the Angular product client and Electron together.
- `npm run server:dev` starts only the server with reload.
- `npm run build` builds the Angular product client to `dist/client`.
- `npm run build:docs` builds the Docusaurus documentation site to `docs-site/build`.
- `npm run build:electron` builds the Electron code to `dist/electron`.
- `npm run build:all` builds the product client, Docusaurus docs, Electron, and server.
- `npm run test` runs the product-client Vitest suite.
- `npm run lint` runs ESLint across the repo.
- `npm run lint:fix` formats Angular templates, sorts template properties, and applies ESLint fixes.
- `npm run test:e2e`, `npm run test:e2e:ui`, `npm run test:e2e:debug`, and `npm run test:e2e:report` run the Playwright suite and report tooling.
Server files: ## Repository Map
- `server/data/variables.json` holds `klipyApiKey`
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to)
- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`)
- `server/data/variables.json` can now also hold `serverPort` (1-65535)
- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP
## Main commands
- `npm run dev` starts Angular, the server, and Electron
- `npm run electron:dev` starts Angular and Electron
- `npm run server:dev` starts only the server
- `npm run build` builds the Angular client
- `npm run build:electron` builds the Electron code
- `npm run build:all` builds client, Electron, and server
- `npm run lint` runs ESLint
- `npm run lint:fix` formats templates, sorts template props, and fixes lint issues
- `npm run test` runs Angular tests
## Server project
The code in `server/` is a small Node and TypeScript service.
It handles the public server directory, join requests, websocket updates, and Klipy routes.
Inside `server/`:
- `npm run dev` starts the server with reload
- `npm run build` compiles to `dist/`
- `npm run start` runs the compiled server
# Images
<img src="./website/src/images/screenshots/gif.png" width="700" height="400">
<img src="./website/src/images/screenshots/screenshare_gaming.png" width="700" height="400">
## Main Toju app Structure
| Path | Description | | Path | Description |
|------|-------------| | --- | --- |
| `src/app/` | Main application root | | `toju-app/src/app/domains/` | Product-client bounded contexts and domain facades |
| `src/app/core/` | Core utilities, services, models | | `toju-app/src/app/infrastructure/` | Shared client-side technical runtime such as persistence and realtime |
| `src/app/domains/` | Domain-driven modules | | `toju-app/src/app/shared-kernel/` | Cross-domain contracts shared inside the product client |
| `src/app/features/` | UI feature modules | | `electron/` | Electron bootstrap, preload surface, IPC handlers, CQRS, and desktop adapters |
| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) | | `server/src/` | Express app, websocket runtime, config, CQRS, and persistence layers |
| `src/app/shared/` | Shared UI components | | `e2e/` | Playwright tests, helpers, fixtures, and page objects |
| `src/app/shared-kernel/` | Shared domain contracts & models | | `website/src/` | Marketing-site pages, assets, and SSR entry points |
| `src/app/store/` | Global state management | | `docs-site/` | Docusaurus source for Electron-hosted application and plugin documentation |
| `src/assets/` | Static assets | | `tools/` | Build, release, formatting, and packaging scripts |
| `src/environments/` | Environment configs |
--- ## Product Client Docs
### Domains | Area | Docs |
| --- | --- |
| Domains index | [toju-app/src/app/domains/README.md](toju-app/src/app/domains/README.md) |
| Access Control | [toju-app/src/app/domains/access-control/README.md](toju-app/src/app/domains/access-control/README.md) |
| Attachment | [toju-app/src/app/domains/attachment/README.md](toju-app/src/app/domains/attachment/README.md) |
| Authentication | [toju-app/src/app/domains/authentication/README.md](toju-app/src/app/domains/authentication/README.md) |
| Chat | [toju-app/src/app/domains/chat/README.md](toju-app/src/app/domains/chat/README.md) |
| Notifications | [toju-app/src/app/domains/notifications/README.md](toju-app/src/app/domains/notifications/README.md) |
| Profile Avatar | [toju-app/src/app/domains/profile-avatar/README.md](toju-app/src/app/domains/profile-avatar/README.md) |
| Screen Share | [toju-app/src/app/domains/screen-share/README.md](toju-app/src/app/domains/screen-share/README.md) |
| Server Directory | [toju-app/src/app/domains/server-directory/README.md](toju-app/src/app/domains/server-directory/README.md) |
| Theme | [toju-app/src/app/domains/theme/README.md](toju-app/src/app/domains/theme/README.md) |
| Voice Connection | [toju-app/src/app/domains/voice-connection/README.md](toju-app/src/app/domains/voice-connection/README.md) |
| Voice Session | [toju-app/src/app/domains/voice-session/README.md](toju-app/src/app/domains/voice-session/README.md) |
| Persistence | [toju-app/src/app/infrastructure/persistence/README.md](toju-app/src/app/infrastructure/persistence/README.md) |
| Realtime | [toju-app/src/app/infrastructure/realtime/README.md](toju-app/src/app/infrastructure/realtime/README.md) |
| Shared Kernel | [toju-app/src/app/shared-kernel/README.md](toju-app/src/app/shared-kernel/README.md) |
| Path | Link | ## Supporting Docs
|------|------|
| Attachment | [app/domains/attachment/README.md](src/app/domains/attachment/README.md) |
| Auth | [app/domains/auth/README.md](src/app/domains/auth/README.md) |
| Chat | [app/domains/chat/README.md](src/app/domains/chat/README.md) |
| Screen Share | [app/domains/screen-share/README.md](src/app/domains/screen-share/README.md) |
| Server Directory | [app/domains/server-directory/README.md](src/app/domains/server-directory/README.md) |
| Voice Connection | [app/domains/voice-connection/README.md](src/app/domains/voice-connection/README.md) |
| Voice Session | [app/domains/voice-session/README.md](src/app/domains/voice-session/README.md) |
| Domains Root | [app/domains/README.md](src/app/domains/README.md) |
--- - [doc/monorepo.md](doc/monorepo.md)
- [doc/typescript.md](doc/typescript.md)
- [docs/architecture.md](docs/architecture.md)
### Infrastructure ## Screenshots
| Path | Link | <img src="./website/src/images/screenshots/gif.png" width="700" height="400">
|------|------| <img src="./website/src/images/screenshots/screenshare_gaming.png" width="700" height="400">
| Persistence | [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) |
| Realtime | [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) |
---
### Shared Kernel
| Path | Link |
|------|------|
| Shared Kernel | [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) |
---
### Entry Points
| File | Link |
|------|------|
| Main | [main.ts](src/main.ts) |
| Index HTML | [index.html](src/index.html) |
| App Root | [app/app.ts](src/app/app.ts) |

View File

@@ -0,0 +1,75 @@
---
sidebar_position: 3
---
# Desktop and Local API
## Electron Hosting Model
The desktop app hosts local documentation through the existing Electron Local API server. This server is implemented with Node's `http` module in the Electron main process and uses async request handlers for routing, file reads, and streamed responses.
The endpoint is manually activated. Opening the Docusaurus docs from the desktop title bar enables the local server and docs endpoint if necessary, then opens the system browser to the generated static site.
This avoids:
- starting a Docusaurus development server inside Electron;
- blocking the renderer thread;
- serving docs from a remote host;
- exposing the endpoint unless the user chooses to activate it.
## Local Server Settings
| Setting | Default | Meaning |
| --- | --- | --- |
| `enabled` | `false` | Starts or stops the local HTTP server. |
| `port` | `17878` | Listening port. |
| `exposeOnLan` | `false` | Uses `127.0.0.1` by default; when true, binds to `0.0.0.0`. |
| `scalarEnabled` | `false` | Enables `/docs` for the Scalar OpenAPI reference. |
| `docusaurusEnabled` | `false` | Enables `/docusaurus` for the built Docusaurus documentation. |
| `allowedSignalingServers` | `[]` | Server URLs allowed for Local API login. |
## Routes
| Endpoint | Purpose | Auth |
| --- | --- | --- |
| `GET /api/health` | Liveness, app version, timestamp, and LAN exposure status. | No |
| `GET /api/openapi.json` | OpenAPI 3.1 document for local automation clients. | No |
| `GET /docs` | Scalar API reference when Scalar docs are enabled. | No |
| `GET /docusaurus` | Docusaurus documentation entrypoint when Docusaurus docs are enabled. | No |
| `GET /docusaurus/*` | Static Docusaurus assets and pages. | No |
| `POST /api/auth/login` | Exchanges username, password, and allowed signaling server URL for a local bearer token. | No |
| `POST /api/auth/logout` | Revokes the current local bearer token. | Bearer |
| `GET /api/profile` | Reads the current local user profile. | Bearer |
| `GET /api/rooms` | Lists rooms known to this device. | Bearer |
| `GET /api/rooms/{roomId}/messages` | Reads local room messages with `limit` and `offset`. | Bearer |
## Authentication Flow
1. Add trusted signaling server URLs in desktop settings.
2. Start the Local API server.
3. Call `POST /api/auth/login` with `username`, `password`, and `serverUrl`.
4. MetoYou validates credentials through the signaling server.
5. The desktop app issues an opaque local bearer token.
6. Use `Authorization: Bearer <token>` for protected routes.
Bearer tokens are local to the running desktop app and are cleared when the Local API server stops.
## Static Documentation Build
Docusaurus is a static site generator. The repo builds `docs-site/` into `docs-site/build/`, and Electron serves those files from the local API server.
Development commands:
```bash
cd docs-site
npm install
npm run start
```
Build command:
```bash
npm run build:docs
```
Packaged desktop builds include the generated static output as an Electron extra resource.

View File

@@ -0,0 +1,87 @@
---
sidebar_position: 1
---
# Contributing
MetoYou is an npm-managed monorepo.
## Packages
| Path | Purpose |
| --- | --- |
| `toju-app/` | Angular renderer, chat client, voice UI, plugin runtime. |
| `electron/` | Electron main process, preload bridge, local database, local REST API, docs host. |
| `server/` | Node/TypeScript signaling server and server-directory HTTP API. |
| `website/` | Angular marketing site. |
| `docs-site/` | Docusaurus documentation site. |
| `e2e/` | Playwright browser and WebRTC tests. |
## Setup
Install root dependencies:
```bash
npm install
```
Install server dependencies when working on the signaling server:
```bash
cd server
npm install
```
## Development Commands
From the repository root:
```bash
npm run dev
```
Useful focused commands:
```bash
npm run build
npm run build:electron
npm run build:docs
npm run server:build
npm run lint
npm run test
npm run test:e2e -- tests/chat-dm-flow.spec.ts
```
Run the Docusaurus dev server:
```bash
cd docs-site
npm install
npm run start
```
Build static docs for Electron packaging:
```bash
npm run build:docs
```
## Repository Rules
- Keep changes inside the package that owns the behavior.
- Do not edit generated output in `dist/`, `dist-electron/`, `dist-server/`, `server/dist/`, `.angular/`, or `node_modules/`.
- Renderer-facing Electron capabilities must stay aligned across implementation, preload, and renderer bridge types.
- Signal-server plugin support stores metadata only. Plugin execution belongs to the client runtime.
- Update this documentation when user workflows, plugin APIs, REST routes, DOM structure, or development commands change.
## Documentation Checklist
When you change a related area, update these pages:
| Change | Docs to check |
| --- | --- |
| Voice UI or settings | User Guide: Voice Channels and Calls, Developer Guide: App Pages and DOM Structure. |
| Text channels, messages, DMs | User Guide: Text and Direct Messages, plugin message API pages. |
| Plugin manifest/API/runtime | Plugin Development pages and LLM Plugin Builder Guide. |
| Local REST API routes or schemas | Developer Guide: Local REST API and `electron/api/openapi.ts`. |
| Docusaurus hosting | Developer Guide: Docusaurus Site and Desktop and Local API. |

View File

@@ -0,0 +1,65 @@
---
sidebar_position: 2
---
# Docusaurus Site
The Docusaurus documentation lives in `docs-site/` and builds to static files in `docs-site/build/`.
## Structure
```text
docs-site/
docusaurus.config.ts
sidebars.ts
docs/
intro.md
user-guide/
developer/
plugin-development/
src/css/custom.css
```
## Development
Use the Docusaurus development server while writing docs:
```bash
cd docs-site
npm run start
```
Build the static site:
```bash
npm run build
```
From the repo root, use:
```bash
npm run build:docs
```
## Electron Hosting
Electron serves the built site through the local API server when Docusaurus docs are enabled.
| Route | Purpose |
| --- | --- |
| `/docusaurus` | Docusaurus entrypoint. |
| `/docusaurus/*` | Static Docusaurus assets and generated pages. |
The endpoint is off until the user opens documentation from the desktop app or enables it through local API settings. Electron serves static files only; it does not run `docusaurus start`.
## Sidebar Rules
Navigation is controlled by `docs-site/sidebars.ts`. Add every new page there unless it is intentionally hidden. Use categories for larger sections so non-technical users can find the user guide separately from developer material.
## Content Rules
- User docs should avoid implementation jargon.
- Developer docs should name exact files, commands, routes, capabilities, and data shapes.
- Plugin API examples should use literal sample input data.
- REST docs should stay aligned with `electron/api/openapi.ts` and `electron/api/router.ts`.
- DOM docs should stay aligned with Angular routes and component selectors.

View File

@@ -0,0 +1,145 @@
---
sidebar_position: 3
---
# App Pages and DOM Structure
This page maps the app routes and important DOM areas. It is useful for plugin authors, testers, and contributors who need stable mental models of where UI mounts.
## Angular Routes
| Route | Component | Purpose |
| --- | --- | --- |
| `/` | Redirect | Redirects to `/search`. |
| `/login` | `LoginComponent` | User login. |
| `/register` | `RegisterComponent` | User registration. |
| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. |
| `/search` | `ServerSearchComponent` | Search and join servers. |
| `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. |
| `/dm` | `DmWorkspaceComponent` | Direct-message workspace. |
| `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. |
| `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. |
| `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. |
| `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. |
## Page Shell
The renderer is an Angular app. The common shell contains router outlet content plus persistent app surfaces such as the server rail, title bar integrations, settings modals, and floating voice controls.
High-level structure:
```html
<app-root>
<router-outlet></router-outlet>
<!-- global dialogs, overlays, floating voice controls, and desktop integrations -->
</app-root>
```
## Server Page DOM
The server page is the most important page for plugins.
```html
<app-chat-room>
<app-servers-rail></app-servers-rail>
<app-rooms-side-panel>
<section>Text Channels</section>
<section>Voice Channels</section>
<section data-testid="plugin-room-side-panel">
<app-plugin-render-host></app-plugin-render-host>
</section>
<section>Members</section>
</app-rooms-side-panel>
<main>
<app-voice-workspace></app-voice-workspace>
<app-chat-messages>
<app-message-list></app-message-list>
<app-typing-indicator></app-typing-indicator>
<app-message-composer></app-message-composer>
<app-klipy-gif-picker></app-klipy-gif-picker>
</app-chat-messages>
</main>
</app-chat-room>
```
## Text Channel Area
Text channel UI is owned by the chat domain.
```html
<app-chat-messages>
<app-message-list>
<app-message-item></app-message-item>
</app-message-list>
<app-message-overlays></app-message-overlays>
<app-typing-indicator></app-typing-indicator>
<app-message-composer></app-message-composer>
</app-chat-messages>
```
Plugin touchpoints:
- `api.ui.registerComposerAction()` adds composer actions.
- `api.ui.registerEmbedRenderer()` renders declared custom embed payloads.
- `api.ui.mountElement()` can mount into a selector such as `app-chat-messages` when the plugin has `ui.dom`.
## Voice Area
Voice UI is split between channel membership, controls, and media workspace.
```html
<app-rooms-side-panel>
<section>Voice Channels</section>
</app-rooms-side-panel>
<app-voice-controls></app-voice-controls>
<app-floating-voice-controls></app-floating-voice-controls>
<app-voice-workspace>
<app-voice-workspace-stream-tile></app-voice-workspace-stream-tile>
</app-voice-workspace>
```
Plugin touchpoints:
- `api.media.playAudioClip()` plays local audio.
- `api.media.addCustomAudioStream()` contributes audio to voice handling.
- `api.media.addCustomVideoStream()` contributes a video stream.
- `api.channels.addAudioChannel()` creates a voice channel entry when the plugin has channel management rights.
## Plugin Store and Manager DOM
```html
<app-plugin-store>
<!-- source management, search, plugin cards, install/update/uninstall actions -->
</app-plugin-store>
<app-plugin-manager>
<!-- installed plugins, capability grants, activate/reload/unload, logs, docs -->
</app-plugin-manager>
```
Plugin pages registered through `api.ui.registerAppPage()` render at `/plugins/:pluginId/:pageId`:
```html
<app-plugin-page-host>
<app-plugin-render-host></app-plugin-render-host>
</app-plugin-page-host>
```
## Plugin Render Host
`PluginRenderHostComponent` accepts plugin render functions that return either an `HTMLElement` or a string. Returning an `HTMLElement` is preferred for interactive UI. Returned strings are rendered as simple text content.
## Stable Selectors for Tests and Plugins
Prefer plugin APIs over DOM selectors. When direct DOM mounting is necessary, use stable app selectors and keep cleanup through the returned disposable.
Common targets:
| Selector | Area |
| --- | --- |
| `body` | Global overlays or modals. |
| `app-chat-messages` | Main text channel surface. |
| `app-rooms-side-panel` | Server side panel. |
| `[data-testid="plugin-room-side-panel"]` | Plugin side-panel area in the server sidebar. |
Avoid depending on Tailwind utility classes; they are layout details and may change.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
---
sidebar_position: 4
---
# Local REST API
The MetoYou desktop app exposes an optional local HTTP API for scripts and tools. It is implemented in Electron and reads local desktop data.
## Enable the API
1. Open Settings.
2. Open Local API settings.
3. Enable the local server.
4. Choose a port. The default is `17878`.
5. Add trusted signaling server URLs for authentication.
6. Enable Scalar docs if you want `/docs`.
7. Enable Docusaurus docs if you want `/docusaurus`.
By default the server binds to `127.0.0.1`. Only enable LAN exposure when you understand the risk.
## Authentication
Protected routes require a bearer token. Get one by posting username, password, and an allowed signaling server URL.
```bash
curl -s http://127.0.0.1:17878/api/auth/login \
-H 'Content-Type: application/json' \
-d '{
"username": "alice",
"password": "correct horse battery staple",
"serverUrl": "https://tojusignal.example.com"
}'
```
Example response:
```json
{
"token": "local_4cddf95c5b8c4b6f9e0c",
"expiresAt": 1777477200000,
"user": {
"id": "user-alice-01",
"username": "alice",
"displayName": "Alice"
}
}
```
Use the token:
```bash
curl -s http://127.0.0.1:17878/api/profile \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
Logout revokes the current token:
```bash
curl -i -X POST http://127.0.0.1:17878/api/auth/logout \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
## OpenAPI and Scalar
| Route | Auth | Purpose |
| --- | --- | --- |
| `GET /api/openapi.json` | No | OpenAPI 3.1 document. |
| `GET /docs` | No | Scalar API reference when enabled. |
## Public Routes
### GET /api/health
Checks whether the local API server is running.
```bash
curl -s http://127.0.0.1:17878/api/health
```
Example response:
```json
{
"status": "ok",
"version": "1.0.0",
"timestamp": 1777473600000,
"exposeOnLan": false
}
```
### GET /api/openapi.json
Returns the machine-readable API document.
```bash
curl -s http://127.0.0.1:17878/api/openapi.json
```
### POST /api/auth/login
Issues a local bearer token after credentials are validated by an allowed signaling server.
Request body:
```json
{
"username": "alice",
"password": "correct horse battery staple",
"serverUrl": "https://tojusignal.example.com"
}
```
Common errors:
| Status | Error code | Meaning |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | Missing username, password, or server URL. |
| 403 | `NO_ALLOWED_SERVERS` | No allowed signaling servers are configured. |
| 403 | `SERVER_NOT_ALLOWED` | The server URL is not in the allowed list. |
| 401 | `INVALID_CREDENTIALS` | Signaling server rejected the login. |
| 502 | `UPSTREAM_UNREACHABLE` | The signaling server could not be reached. |
## Protected Routes
All routes below require:
```http
Authorization: Bearer local_4cddf95c5b8c4b6f9e0c
```
### GET /api/profile
Reads the current local user profile.
```bash
curl -s http://127.0.0.1:17878/api/profile \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
### GET /api/rooms
Lists rooms known to this device.
```bash
curl -s http://127.0.0.1:17878/api/rooms \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
### GET `/api/rooms/{roomId}`
Reads one room by id.
```bash
curl -s http://127.0.0.1:17878/api/rooms/room-7ebdde75 \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
### GET `/api/rooms/{roomId}/users`
Lists users known for a room.
```bash
curl -s http://127.0.0.1:17878/api/rooms/room-7ebdde75/users \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
### GET `/api/rooms/{roomId}/messages`
Lists local messages for a room. `limit` defaults to `100` and is clamped from `1` to `500`. `offset` defaults to `0`.
```bash
curl -s 'http://127.0.0.1:17878/api/rooms/room-7ebdde75/messages?limit=50&offset=0' \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
### GET `/api/rooms/{roomId}/messages/since`
Lists local messages after a required timestamp.
```bash
curl -s 'http://127.0.0.1:17878/api/rooms/room-7ebdde75/messages/since?sinceTimestamp=1777470000000' \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
### GET `/api/rooms/{roomId}/bans`
Lists active bans for a room.
```bash
curl -s http://127.0.0.1:17878/api/rooms/room-7ebdde75/bans \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
### GET `/api/rooms/{roomId}/bans/{userId}`
Checks whether a user is banned in a room.
```bash
curl -s http://127.0.0.1:17878/api/rooms/room-7ebdde75/bans/user-muse-01 \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
Example response:
```json
{ "isBanned": false }
```
### GET `/api/messages/{messageId}`
Reads one local message by id.
```bash
curl -s http://127.0.0.1:17878/api/messages/msg-20260429-001 \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
### GET `/api/messages/{messageId}/reactions`
Lists reactions for a message.
```bash
curl -s http://127.0.0.1:17878/api/messages/msg-20260429-001/reactions \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
### GET `/api/messages/{messageId}/attachments`
Lists attachments for a message.
```bash
curl -s http://127.0.0.1:17878/api/messages/msg-20260429-001/attachments \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
### GET `/api/users/{userId}`
Reads one user by id.
```bash
curl -s http://127.0.0.1:17878/api/users/user-muse-01 \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
### GET /api/attachments
Lists all attachments stored on this device.
```bash
curl -s http://127.0.0.1:17878/api/attachments \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
### GET /api/plugin-data
Reads a plugin data value from the local desktop database. `scope` must be `local` or `server`. Provide `serverId` when reading server-scoped data.
```bash
curl -s 'http://127.0.0.1:17878/api/plugin-data?pluginId=example.soundboard&key=favorites&scope=server&serverId=room-7ebdde75' \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
Example response:
```json
{
"value": [
{ "label": "Chime", "url": "https://cdn.example.com/chime.wav" }
]
}
```
### GET `/api/meta/{key}`
Reads a desktop metadata value by key.
```bash
curl -s http://127.0.0.1:17878/api/meta/metoyou_currentUserId \
-H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c'
```
Example response:
```json
{
"key": "metoyou_currentUserId",
"value": "user-alice-01"
}
```
## Data Model Notes
Rooms, users, messages, reactions, attachments, and bans are returned from local desktop persistence. Many schemas allow additional properties because the local database can carry richer app state than the REST docs need to guarantee.
## Security Notes
- Keep the API bound to `127.0.0.1` unless LAN access is required.
- Only add signaling servers you trust to the allowed list.
- Bearer tokens are local to the running desktop app.
- Stop the local API server to clear issued tokens.

48
docs-site/docs/intro.md Normal file
View File

@@ -0,0 +1,48 @@
---
slug: /
sidebar_position: 1
---
# MetoYou Documentation
MetoYou is a desktop-first chat app with text channels, voice channels, direct messages, plugins, local desktop storage, a local REST API, and a Docusaurus documentation site bundled into the app.
This site is split into three paths:
- **User Guide** explains the app in non-technical terms: servers, text channels, voice channels, screen sharing, direct messages, plugins, and desktop settings.
- **Developer Guide** explains how to run the repo, how the app is structured, how Docusaurus is served, the app DOM/page structure, and the local REST API.
- **Plugin Development** explains how to build plugins, declare capabilities, distribute bundles, and call every exposed plugin API with concrete examples.
The Electron app can host this documentation locally. The docs endpoint is not a separate web server process: it is served from the same opt-in local HTTP server used for the Local API, and it only serves static files generated by Docusaurus.
## What Is Included
| Area | What it covers |
| --- | --- |
| Product client | Login, server discovery, channels, messages, voice, direct messages, themes, and plugin UI. |
| Desktop shell | Window controls, notifications, tray behavior, app data import/export, updates, local plugins, and hosted documentation. |
| Local HTTP API | A loopback-first API for local scripts and tools, with OpenAPI and Scalar reference docs. |
| Plugin runtime | Browser-safe client plugins with explicit capabilities, lifecycle hooks, UI contributions, data storage, message bus, and server plugin requirements. |
## Runtime Boundaries
MetoYou keeps responsibilities split by package:
- `toju-app/` is the Angular product client and plugin runtime.
- `electron/` is the main process, preload bridge, IPC, local persistence, and local HTTP host.
- `server/` is the signaling and server-directory service.
- `e2e/` contains Playwright coverage for browser and WebRTC workflows.
- `docs-site/` is this Docusaurus site.
The desktop documentation endpoint serves the static `docs-site/build` output. It does not run the Docusaurus development server inside Electron.
## Fast Links
- Start using the app: [First Steps](./user-guide/first-steps.md)
- Join voice: [Voice Channels and Calls](./user-guide/voice-channels.md)
- Install plugins: [Plugins for Users](./user-guide/plugins.md)
- Run the repo: [Contributing](./developer/contributing.md)
- Understand pages and DOM: [App Pages and DOM Structure](./developer/dom-structure.md)
- Use the REST API: [Local REST API](./developer/rest-api.md)
- Build a plugin: [Create a Plugin](./plugin-development/create-a-plugin.md)
- Give an LLM plugin context: [LLM Plugin Builder Guide](./developer/llm-plugin-builder-guide.md)

View File

@@ -0,0 +1,329 @@
---
sidebar_position: 4
---
# Plugin API Reference
`TojuClientPluginApi` is the object passed to a plugin activation context. The runtime freezes the API object before passing it to plugin code.
This page is the compact map. Use the focused API pages for concrete copy-paste examples with literal input data.
## Focused API Pages
- [Context and Logging](./api/context-and-logging.md)
- [Profile API](./api/profile.md)
- [Users and Roles API](./api/users-and-roles.md)
- [Server API](./api/server.md)
- [Channels API](./api/channels.md)
- [Messages and Typing API](./api/messages-and-typing.md)
- [Events API](./api/events.md)
- [Message Bus API](./api/message-bus.md)
- [P2P and Media API](./api/p2p-and-media.md)
- [Storage API](./api/storage.md)
- [UI API](./api/ui.md)
## Activation Types
```ts
interface TojuPluginDisposable {
dispose: () => void;
}
interface TojuPluginActivationContext {
api: TojuClientPluginApi;
manifest: TojuPluginManifest;
pluginId: string;
subscriptions: TojuPluginDisposable[];
}
interface TojuClientPluginModule {
activate?: (context: TojuPluginActivationContext) => Promise<void> | void;
deactivate?: (context: TojuPluginActivationContext) => Promise<void> | void;
onPluginDataChanged?: (context: TojuPluginActivationContext, event: unknown) => Promise<void> | void;
onServerRequirementsChanged?: (context: TojuPluginActivationContext, snapshot: PluginRequirementsSnapshot) => Promise<void> | void;
ready?: (context: TojuPluginActivationContext) => Promise<void> | void;
}
```
## Profiles
```ts
interface PluginApiProfileUpdate {
description?: string;
displayName: string;
}
interface PluginApiAvatarUpdate {
avatarHash: string;
avatarMime: string;
avatarUrl: string;
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `profile.getCurrent()` | `profile.read` | Returns the current `User` or `null`. |
| `profile.update(profile)` | `profile.write` | Updates display name and optional description. |
| `profile.updateAvatar(avatar)` | `profile.write` | Updates avatar URL, MIME type, and hash metadata. |
## Users and Roles
| Method | Capability | Description |
| --- | --- | --- |
| `users.getCurrent()` | `users.read` | Returns current `User` or `null`. |
| `users.list()` | `users.read` | Returns known users. |
| `users.readMembers()` | `users.read` | Returns active room members. |
| `users.setRole(userId, role)` | `roles.manage` | Updates a user's role. |
| `users.kick(userId)` | `users.manage` | Kicks a user. |
| `users.ban(userId, reason?)` | `users.manage` | Bans a user with optional reason. |
| `roles.list()` | `roles.read` | Returns room roles. |
| `roles.setAssignments(assignments)` | `roles.manage` | Replaces role assignments. |
## Server
```ts
interface PluginApiServerSettingsUpdate {
description?: string;
isPrivate?: boolean;
maxUsers?: number;
name?: string;
password?: string;
topic?: string;
}
interface PluginApiPluginUserRequest {
avatarUrl?: string;
displayName: string;
id?: string;
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `server.getCurrent()` | `server.read` | Returns the current `Room` or `null`. |
| `server.registerPluginUser(request)` | `users.manage` | Adds a plugin-owned user and returns its id. |
| `server.updatePermissions(permissions)` | `server.manage` | Updates partial room permissions. |
| `server.updateSettings(settings)` | `server.manage` | Updates room settings. |
## Channels
```ts
interface PluginApiChannelRequest {
id?: string;
name: string;
position?: number;
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `channels.list()` | `channels.read` | Returns current room channels. |
| `channels.select(channelId)` | `channels.read` | Selects a channel. |
| `channels.addAudioChannel(request)` | `channels.manage` | Adds a voice channel. |
| `channels.addVideoChannel(request)` | `channels.manage` | Registers a video channel section. |
| `channels.rename(channelId, name)` | `channels.manage` | Renames a channel. |
| `channels.remove(channelId)` | `channels.manage` | Removes a channel. |
## Messages
```ts
interface PluginApiMessageAsPluginUserRequest {
channelId?: string;
content: string;
pluginUserId: string;
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `messages.readCurrent()` | `messages.read` | Returns current visible messages. |
| `messages.send(content, channelId?)` | `messages.send` | Sends a message and returns the created `Message`. |
| `messages.sendAsPluginUser(request)` | `messages.send` | Emits a message from a registered plugin user. |
| `messages.setTyping(isTyping, channelId?)` | `messages.send` | Broadcasts current typing state for a channel. |
| `messages.subscribeTyping(handler)` | `messages.read` | Subscribes to peer typing state. |
| `messages.edit(messageId, content)` | `messages.editOwn` | Edits a plugin message. |
| `messages.delete(messageId)` | `messages.deleteOwn` | Deletes a plugin message. |
| `messages.moderateDelete(messageId)` | `messages.moderate` | Performs a moderation delete. |
| `messages.sync(messages)` | `messages.sync` | Syncs an array of messages into state. |
## Events
```ts
interface PluginApiEventSubscription {
eventName: string;
handler: (event: PluginEventEnvelope) => void;
}
interface PluginEventEnvelope<TPayload = unknown> {
emittedAt?: number;
eventId?: string;
eventName: string;
payload: TPayload;
pluginId: string;
serverId: string;
sourcePluginUserId?: string;
sourceUserId?: string;
type: 'plugin_event';
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `events.publishServer(eventName, payload)` | `events.server.publish` | Sends a declared plugin event through the signaling server. |
| `events.subscribeServer(subscription)` | `events.server.subscribe` | Subscribes to a declared server plugin event. |
| `events.publishP2p(eventName, payload)` | `events.p2p.publish` | Sends a declared plugin event over peer paths. |
| `events.subscribeP2p(subscription)` | `events.p2p.subscribe` | Registers a P2P event subscription. |
## Message Bus
```ts
interface PluginApiMessageBusEnvelope {
channelId?: string;
eventId: string;
messages?: Message[];
payload?: unknown;
pluginId: string;
roomId: string;
sentAt: number;
sourcePeerId?: string;
sourceUserId?: string;
topic: string;
}
interface PluginApiMessageBusLatestRequest {
channelId?: string;
includeDeleted?: boolean;
limit?: number;
sinceTimestamp?: number;
targetPeerId?: string;
topic?: string;
}
interface PluginApiMessageBusPublishRequest extends PluginApiMessageBusLatestRequest {
includeLatestMessages?: boolean;
includeSelf?: boolean;
payload?: unknown;
topic: string;
}
interface PluginApiMessageBusSubscription {
channelId?: string;
handler: (event: PluginApiMessageBusEnvelope) => void;
latestMessageLimit?: number;
replayLatest?: boolean;
topic?: string;
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `messageBus.publish(request)` | `events.p2p.publish`, optionally `messages.read` | Publishes a plugin-bus event, optionally including latest messages. |
| `messageBus.sendLatestMessages(request?)` | `events.p2p.publish` and `messages.read` | Sends a latest-message snapshot. |
| `messageBus.subscribe(subscription)` | `events.p2p.subscribe`, optionally `messages.read` | Subscribes to plugin-bus events, optionally replaying latest messages. |
## P2P and Media
```ts
interface PluginApiAudioClipRequest {
volume?: number;
url: string;
}
interface PluginApiCustomStreamRequest {
label?: string;
stream: MediaStream;
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `p2p.connectedPeers()` | `p2p.data` | Returns connected peer ids. |
| `p2p.broadcastData(eventName, payload)` | `p2p.data` | Broadcasts plugin data. |
| `p2p.sendData(peerId, eventName, payload)` | `p2p.data` | Sends plugin data targeted to a peer. |
| `media.playAudioClip(request)` | `media.playAudio` | Plays an audio URL at optional volume. |
| `media.addCustomAudioStream(request)` | `media.addAudioStream` | Contributes an audio `MediaStream`. |
| `media.addCustomVideoStream(request)` | `media.addVideoStream` | Registers a video `MediaStream` contribution. |
| `media.setInputVolume(volume)` | `audio.volume` | Sets local input volume. |
| `media.setOutputVolume(volume)` | `audio.volume` | Sets local output volume. |
## Storage
| Method | Capability | Description |
| --- | --- | --- |
| `clientData.read(key)` | `storage.local` | Reads async plugin-local data. |
| `clientData.write(key, value)` | `storage.local` | Writes async plugin-local data. |
| `clientData.remove(key)` | `storage.local` | Removes async plugin-local data. |
| `serverData.read(key)` | `storage.serverData.read` | Reads local per-user/per-server data. |
| `serverData.write(key, value)` | `storage.serverData.write` | Writes local per-user/per-server data. |
| `serverData.remove(key)` | `storage.serverData.write` | Removes local per-user/per-server data. |
| `storage.get(key)` | `storage.local` | Legacy synchronous local read. |
| `storage.set(key, value)` | `storage.local` | Legacy synchronous local write. |
| `storage.remove(key)` | `storage.local` | Legacy synchronous local remove. |
## UI Contributions
```ts
interface PluginApiActionContribution {
icon?: string;
label: string;
run: () => Promise<void> | void;
}
interface PluginApiPageContribution {
label: string;
path: string;
render: () => HTMLElement | string;
}
interface PluginApiPanelContribution {
label: string;
order?: number;
render: () => HTMLElement | string;
}
interface PluginApiSettingsPageContribution {
label: string;
order?: number;
render: () => HTMLElement | string;
settingsKey?: string;
}
interface PluginApiChannelSectionContribution {
label: string;
order?: number;
type?: 'audio' | 'custom' | 'video';
}
interface PluginApiEmbedRendererContribution {
embedType: string;
render: (payload: unknown) => HTMLElement | string;
}
interface PluginApiDomMountRequest {
element: HTMLElement;
position?: InsertPosition;
target: Element | string;
}
```
| Method | Capability | Description |
| --- | --- | --- |
| `ui.registerAppPage(id, contribution)` | `ui.pages` | Adds a plugin app page. |
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` | Adds a plugin settings page. |
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` | Adds a side panel. |
| `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` | Adds a channel section. |
| `ui.registerComposerAction(id, contribution)` | `ui.pages` | Adds a composer action. |
| `ui.registerProfileAction(id, contribution)` | `ui.pages` | Adds a profile action. |
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` | Adds a toolbar action. |
| `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` | Adds an embed renderer. |
| `ui.mountElement(id, request)` | `ui.dom` | Mounts plugin-owned DOM into a target element or selector. |
## Context and Logger
| Method | Capability | Description |
| --- | --- | --- |
| `context.getCurrent()` | None | Reads current user, server, active text channel, and active voice channel. |
| `logger.debug(message, data?)` | None | Writes a debug plugin log entry. |
| `logger.info(message, data?)` | None | Writes an info plugin log entry. |
| `logger.warn(message, data?)` | None | Writes a warning plugin log entry. |
| `logger.error(message, data?)` | None | Writes an error plugin log entry. |

View File

@@ -0,0 +1,85 @@
---
sidebar_position: 5
---
# Channels API
The channels API reads, selects, creates, renames, and removes server channels.
## Required Capabilities
| Method | Capability |
| --- | --- |
| `channels.list()` | `channels.read` |
| `channels.select(channelId)` | `channels.read` |
| `channels.addAudioChannel(request)` | `channels.manage` |
| `channels.addVideoChannel(request)` | `channels.manage` |
| `channels.rename(channelId, name)` | `channels.manage` |
| `channels.remove(channelId)` | `channels.manage` |
## List Channels
```js
export function activate(context) {
const channels = context.api.channels.list();
context.api.logger.info('Channels', channels.map((channel) => ({
id: channel.id,
name: channel.name,
type: channel.type
})));
}
```
Example channel list:
```json
[
{ "id": "general", "name": "general", "type": "text", "position": 0 },
{ "id": "support", "name": "support", "type": "text", "position": 1 },
{ "id": "lobby", "name": "Lobby", "type": "audio", "position": 10 }
]
```
## Select a Channel
```js
export function activate(context) {
context.api.channels.select('support');
}
```
## Add a Voice Channel
```js
export function activate(context) {
context.api.channels.addAudioChannel({
id: 'raid-voice',
name: 'Raid Voice',
position: 20
});
}
```
## Add a Video Channel Section
```js
export function activate(context) {
context.api.channels.addVideoChannel({
id: 'watch-party-video',
name: 'Watch Party',
position: 30
});
}
```
## Rename and Remove
```js
export function activate(context) {
context.api.channels.rename('raid-voice', 'Raid Voice - Tonight');
context.api.channels.remove('old-event-room');
}
```
Channel creation, rename, and removal should be user-confirmed because they change the shared server structure.

View File

@@ -0,0 +1,73 @@
---
sidebar_position: 1
---
# Context and Logging
Context and logging are available to every plugin. They do not require privileged capabilities.
## context.getCurrent()
Reads the current interaction context.
```js
export function activate(context) {
const current = context.api.context.getCurrent();
context.api.logger.info('Current context', {
serverName: current.server?.name ?? 'No server open',
textChannel: current.textChannel?.name ?? 'No text channel selected',
voiceChannel: current.voiceChannel?.name ?? 'Not connected to voice',
user: current.user?.displayName ?? 'No user'
});
}
```
Example context shape:
```json
{
"source": "manual",
"server": { "id": "room-7ebdde75", "name": "Friday Game Night" },
"textChannel": { "id": "general", "name": "general", "type": "text" },
"voiceChannel": { "id": "lobby", "name": "Lobby", "type": "audio" },
"user": { "id": "user-alice-01", "displayName": "Alice" }
}
```
## Action Context
Composer, toolbar, and profile actions receive context directly.
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerToolbarAction('where-am-i', {
label: 'Where am I?',
run: (actionContext) => {
context.api.logger.info('Toolbar action context', {
source: actionContext.source,
serverId: actionContext.server?.id,
textChannelId: actionContext.textChannel?.id,
voiceChannelId: actionContext.voiceChannel?.id
});
}
}));
}
```
Capability required: `ui.pages` for the toolbar action. The context object itself needs no extra capability.
## Logger Methods
```js
export function activate(context) {
const { logger } = context.api;
logger.debug('Preparing plugin', { pluginId: context.pluginId });
logger.info('Plugin activated', { version: context.manifest.version });
logger.warn('Optional service unavailable', { service: 'weather.example.com' });
logger.error('Failed to parse saved preference', { key: 'soundboard:favorites' });
}
```
Logs are visible in the Plugin Manager. Avoid logging passwords, bearer tokens, or private message contents.

View File

@@ -0,0 +1,100 @@
---
sidebar_position: 7
---
# Events API
Plugin events allow plugins to publish and subscribe to declared server or P2P events.
## Required Capabilities
| Method | Capability |
| --- | --- |
| `events.publishServer(eventName, payload)` | `events.server.publish` |
| `events.subscribeServer(subscription)` | `events.server.subscribe` |
| `events.publishP2p(eventName, payload)` | `events.p2p.publish` |
| `events.subscribeP2p(subscription)` | `events.p2p.subscribe` |
## Declare Events in the Manifest
```json
{
"events": [
{
"eventName": "poll:vote",
"direction": "p2pHint",
"scope": "channel",
"maxPayloadBytes": 2048
},
{
"eventName": "moderation:flag",
"direction": "serverRelay",
"scope": "server",
"maxPayloadBytes": 4096
}
]
}
```
## Publish and Subscribe to P2P Events
```js
export function activate(context) {
context.subscriptions.push(context.api.events.subscribeP2p({
eventName: 'poll:vote',
handler: (event) => {
context.api.logger.info('Vote received', {
optionId: event.payload?.optionId,
voterName: event.payload?.voterName,
eventId: event.eventId
});
}
}));
context.api.events.publishP2p('poll:vote', {
pollId: 'raid-night-2026-04-29',
optionId: 'dungeon',
voterName: 'Alice'
});
}
```
## Publish and Subscribe to Server Events
```js
export function activate(context) {
context.subscriptions.push(context.api.events.subscribeServer({
eventName: 'moderation:flag',
handler: (event) => {
context.api.logger.warn('Moderation flag received', {
messageId: event.payload?.messageId,
reason: event.payload?.reason
});
}
}));
context.api.events.publishServer('moderation:flag', {
messageId: 'msg-20260429-flagged',
reason: 'Possible spam link',
reportedBy: 'user-alice-01'
});
}
```
Example event envelope:
```json
{
"type": "plugin_event",
"eventName": "poll:vote",
"pluginId": "example.polls",
"serverId": "room-7ebdde75",
"eventId": "event-1777473600000-1",
"emittedAt": 1777473600000,
"payload": {
"pollId": "raid-night-2026-04-29",
"optionId": "dungeon",
"voterName": "Alice"
}
}
```

View File

@@ -0,0 +1,95 @@
---
sidebar_position: 8
---
# Message Bus API
The plugin message bus sends plugin-only P2P events. It can also include bounded latest-message snapshots for plugins that coordinate around recent chat state.
## Required Capabilities
| Method | Capability |
| --- | --- |
| `messageBus.publish(request)` | `events.p2p.publish`, plus `messages.read` if `includeLatestMessages` is true |
| `messageBus.sendLatestMessages(request?)` | `events.p2p.publish` and `messages.read` |
| `messageBus.subscribe(subscription)` | `events.p2p.subscribe`, plus `messages.read` if replaying latest messages |
## Subscribe
```js
export function activate(context) {
context.subscriptions.push(context.api.messageBus.subscribe({
topic: 'poll:votes',
channelId: 'general',
replayLatest: true,
latestMessageLimit: 10,
handler: (event) => {
context.api.logger.info('Poll bus event', {
topic: event.topic,
choice: event.payload?.choice,
messageCount: event.messages?.length ?? 0
});
}
}));
}
```
## Publish
```js
export function activate(context) {
const envelope = context.api.messageBus.publish({
topic: 'poll:votes',
channelId: 'general',
payload: {
pollId: 'raid-night-2026-04-29',
choice: 'healer',
voter: 'Alice'
},
includeLatestMessages: true,
includeSelf: true,
latestMessageLimit: 10,
sinceTimestamp: 1777470000000
});
context.api.logger.info('Published poll event', { eventId: envelope.eventId });
}
```
Example envelope:
```json
{
"eventId": "plugin-bus-1777473600000-1",
"pluginId": "example.polls",
"roomId": "room-7ebdde75",
"channelId": "general",
"topic": "poll:votes",
"sentAt": 1777473600000,
"payload": {
"pollId": "raid-night-2026-04-29",
"choice": "healer",
"voter": "Alice"
},
"messages": [
{ "id": "msg-1", "content": "Raid tonight?", "channelId": "general" }
]
}
```
## Send Latest Messages
```js
export function activate(context) {
context.api.messageBus.sendLatestMessages({
topic: 'chat:snapshot',
channelId: 'support',
limit: 25,
includeDeleted: false,
sinceTimestamp: 1777460000000,
targetPeerId: 'peer-muse-laptop'
});
}
```
Use the message bus for plugin coordination. Do not use it for normal user chat messages; use `messages.send()` for that.

View File

@@ -0,0 +1,144 @@
---
sidebar_position: 6
---
# Messages and Typing API
The messages API reads current messages, sends messages, edits or deletes plugin-owned messages, moderates messages, syncs messages, and exposes typing state.
## Required Capabilities
| Method | Capability |
| --- | --- |
| `messages.readCurrent()` | `messages.read` |
| `messages.send(content, channelId?)` | `messages.send` |
| `messages.sendAsPluginUser(request)` | `messages.send` |
| `messages.setTyping(isTyping, channelId?)` | `messages.send` |
| `messages.subscribeTyping(handler)` | `messages.read` |
| `messages.edit(messageId, content)` | `messages.editOwn` |
| `messages.delete(messageId)` | `messages.deleteOwn` |
| `messages.moderateDelete(messageId)` | `messages.moderate` |
| `messages.sync(messages)` | `messages.sync` |
## Read Current Messages
```js
export function activate(context) {
const messages = context.api.messages.readCurrent();
context.api.logger.info('Current messages', messages.slice(-3).map((message) => ({
id: message.id,
channelId: message.channelId,
senderName: message.senderName,
content: message.content
})));
}
```
## Send a Message
```js
export function activate(context) {
const created = context.api.messages.send(
'Reminder: raid starts at 20:00. Bring repairs and snacks.',
'general'
);
context.api.logger.info('Sent reminder', { messageId: created.id });
}
```
## Send as a Plugin User
```js
export function activate(context) {
const botUserId = context.api.server.registerPluginUser({
id: 'poll-bot',
displayName: 'Poll Bot'
});
context.api.messages.sendAsPluginUser({
pluginUserId: botUserId,
channelId: 'general',
content: 'Poll is open: react with 1 for dungeon, 2 for arena, 3 for crafting.'
});
}
```
Capabilities required: `users.manage` and `messages.send`.
## Edit and Delete Plugin-Owned Messages
```js
export function activate(context) {
const message = context.api.messages.send('Draft event reminder', 'announcements');
context.api.messages.edit(message.id, 'Event reminder: voice meetup starts in 15 minutes.');
context.api.messages.delete(message.id);
}
```
## Moderation Delete
```js
export function activate(context) {
context.api.messages.moderateDelete('msg-spam-20260429-001');
}
```
Use moderation from explicit moderator actions, not automatic activation.
## Typing State
```js
export function activate(context) {
context.api.messages.setTyping(true, 'general');
setTimeout(() => {
context.api.messages.setTyping(false, 'general');
}, 1500);
context.subscriptions.push(context.api.messages.subscribeTyping((event) => {
context.api.logger.info('Typing event', {
displayName: event.displayName,
isTyping: event.isTyping,
channelId: event.channelId,
serverId: event.serverId,
voiceChannel: event.voiceChannel?.name ?? null
});
}));
}
```
Example typing event:
```json
{
"serverId": "room-7ebdde75",
"channelId": "general",
"userId": "user-muse-01",
"displayName": "Muse",
"isTyping": true
}
```
## Sync Messages
```js
export function activate(context) {
context.api.messages.sync([
{
id: 'external-standup-001',
roomId: 'room-7ebdde75',
channelId: 'standup',
senderId: 'standup-importer',
senderName: 'Standup Importer',
content: 'Imported note: Alice is working on plugin docs.',
timestamp: 1777473600000,
isDeleted: false
}
]);
}
```
Sync should preserve message ids and timestamps from the source system when possible.

View File

@@ -0,0 +1,128 @@
---
sidebar_position: 9
---
# P2P and Media API
P2P APIs send plugin data to connected peers. Media APIs play audio and contribute custom streams.
## Required Capabilities
| Method | Capability |
| --- | --- |
| `p2p.connectedPeers()` | `p2p.data` |
| `p2p.broadcastData(eventName, payload)` | `p2p.data` |
| `p2p.sendData(peerId, eventName, payload)` | `p2p.data` |
| `media.playAudioClip(request)` | `media.playAudio` |
| `media.addCustomAudioStream(request)` | `media.addAudioStream` |
| `media.addCustomVideoStream(request)` | `media.addVideoStream` |
| `media.setInputVolume(volume)` | `audio.volume` |
| `media.setOutputVolume(volume)` | `audio.volume` |
## Connected Peers
```js
export function activate(context) {
const peerIds = context.api.p2p.connectedPeers();
context.api.logger.info('Connected peers', { peerIds });
}
```
## Broadcast Data
```js
export function activate(context) {
context.api.p2p.broadcastData('soundboard:played', {
soundId: 'airhorn-short',
label: 'Airhorn',
playedBy: 'Alice',
playedAt: 1777473600000
});
}
```
## Send Data to One Peer
```js
export function activate(context) {
context.api.p2p.sendData('peer-muse-laptop', 'private-tool:ping', {
requestId: 'ping-20260429-001',
message: 'Are you receiving plugin data?'
});
}
```
## Play an Audio Clip
```js
export async function activate(context) {
await context.api.media.playAudioClip({
url: 'https://cdn.example.com/metoyou/sounds/chime.wav',
volume: 0.65
});
}
```
## Add a Custom Audio Stream
```js
export async function activate(context) {
const audioContext = new AudioContext();
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
const destination = audioContext.createMediaStreamDestination();
oscillator.type = 'sine';
oscillator.frequency.value = 440;
gain.gain.value = 0.03;
oscillator.connect(gain);
gain.connect(destination);
oscillator.start();
await context.api.media.addCustomAudioStream({
label: 'Tuning tone',
stream: destination.stream
});
setTimeout(async () => {
oscillator.stop();
await audioContext.close();
}, 1000);
}
```
## Add a Custom Video Stream
```js
export async function activate(context) {
const canvas = document.createElement('canvas');
canvas.width = 1280;
canvas.height = 720;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#111827';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ffffff';
ctx.font = '48px sans-serif';
ctx.fillText('Plugin camera scene', 80, 120);
const stream = canvas.captureStream(15);
await context.api.media.addCustomVideoStream({
label: 'Plugin camera scene',
stream
});
}
```
## Set Volumes
```js
export function activate(context) {
context.api.media.setInputVolume(0.85);
context.api.media.setOutputVolume(0.75);
}
```
Use media APIs with visible controls and clear user consent. Unexpected audio or video is a poor user experience.

View File

@@ -0,0 +1,66 @@
---
sidebar_position: 2
---
# Profile API
The profile API reads and updates the current user's local profile details.
## Required Capabilities
| Method | Capability |
| --- | --- |
| `profile.getCurrent()` | `profile.read` |
| `profile.update(profile)` | `profile.write` |
| `profile.updateAvatar(avatar)` | `profile.write` |
## Read Current Profile
```js
export function activate(context) {
const user = context.api.profile.getCurrent();
context.api.logger.info('Current profile', {
id: user?.id,
displayName: user?.displayName,
username: user?.username
});
}
```
Example result:
```json
{
"id": "user-alice-01",
"username": "alice",
"displayName": "Alice",
"description": "Raids on Fridays",
"avatarUrl": "/avatars/alice.webp"
}
```
## Update Display Profile
```js
export function activate(context) {
context.api.profile.update({
displayName: 'Alice - Support Lead',
description: 'Available for onboarding and support questions.'
});
}
```
## Update Avatar
```js
export function activate(context) {
context.api.profile.updateAvatar({
avatarUrl: 'https://cdn.example.com/metoyou/avatars/alice-support.png',
avatarMime: 'image/png',
avatarHash: 'sha256:9df5d5e4b0d8f41f3a3cf5d1f5a2c1f4'
});
}
```
Use `profile.write` carefully. A plugin that changes a user's identity should explain why in its readme and UI.

View File

@@ -0,0 +1,81 @@
---
sidebar_position: 4
---
# Server API
The server API reads the active server, registers plugin-owned users, and updates server settings or permissions.
## Required Capabilities
| Method | Capability |
| --- | --- |
| `server.getCurrent()` | `server.read` |
| `server.registerPluginUser(request)` | `users.manage` |
| `server.updatePermissions(permissions)` | `server.manage` |
| `server.updateSettings(settings)` | `server.manage` |
## Read Current Server
```js
export function activate(context) {
const server = context.api.server.getCurrent();
context.api.logger.info('Current server', {
id: server?.id,
name: server?.name,
topic: server?.topic,
isPrivate: server?.isPrivate
});
}
```
## Register a Plugin User
Plugin users are useful for bot-style messages.
```js
export function activate(context) {
const botUserId = context.api.server.registerPluginUser({
id: 'standup-helper-bot',
displayName: 'Standup Helper',
avatarUrl: 'https://cdn.example.com/metoyou/plugins/standup-helper.png'
});
context.api.messages.sendAsPluginUser({
pluginUserId: botUserId,
channelId: 'general',
content: 'Standup reminder: share yesterday, today, and blockers.'
});
}
```
Capabilities required: `users.manage` and `messages.send`.
## Update Server Settings
```js
export function activate(context) {
context.api.server.updateSettings({
name: 'Friday Game Night',
topic: 'Co-op games, voice chat, and clips',
description: 'A friendly server for Friday sessions.',
maxUsers: 64,
isPrivate: false
});
}
```
## Update Permissions
```js
export function activate(context) {
context.api.server.updatePermissions({
allowVoice: true,
allowVideo: true,
allowScreenShare: true
});
}
```
Only update settings or permissions as part of an explicit admin flow. Plugins should not silently rename servers or change access rules.

View File

@@ -0,0 +1,101 @@
---
sidebar_position: 10
---
# Storage API
Plugins can store local client data and per-server data. Desktop builds use Electron persistence when available; browser fallback uses renderer storage.
## Required Capabilities
| Method | Capability |
| --- | --- |
| `clientData.read(key)` | `storage.local` |
| `clientData.write(key, value)` | `storage.local` |
| `clientData.remove(key)` | `storage.local` |
| `serverData.read(key)` | `storage.serverData.read` |
| `serverData.write(key, value)` | `storage.serverData.write` |
| `serverData.remove(key)` | `storage.serverData.write` |
| `storage.get(key)` | `storage.local` |
| `storage.set(key, value)` | `storage.local` |
| `storage.remove(key)` | `storage.local` |
## Client Data
Client data belongs to this local user and client.
```js
export async function activate(context) {
await context.api.clientData.write('soundboard:volume', {
masterVolume: 0.7,
updatedAt: 1777473600000
});
const value = await context.api.clientData.read('soundboard:volume');
context.api.logger.info('Loaded client data', value);
}
```
## Server Data
Server data is local per-user/per-server state. It is not arbitrary signal-server persistence.
```js
export async function activate(context) {
await context.api.serverData.write('soundboard:favorites', [
{ id: 'chime', label: 'Chime', url: 'https://cdn.example.com/chime.wav' },
{ id: 'ready', label: 'Ready Check', url: 'https://cdn.example.com/ready.wav' }
]);
const favorites = await context.api.serverData.read('soundboard:favorites');
context.api.logger.info('Loaded server favorites', favorites);
}
```
## Remove Data
```js
export async function activate(context) {
await context.api.clientData.remove('soundboard:volume');
await context.api.serverData.remove('soundboard:favorites');
}
```
## Legacy Synchronous Storage
The `storage.*` methods are legacy local storage helpers. Prefer `clientData.*` for new plugins when async reads are acceptable.
```js
export function activate(context) {
context.api.storage.set('quick-toggle', { enabled: true });
const saved = context.api.storage.get('quick-toggle');
context.api.logger.info('Legacy storage value', saved);
context.api.storage.remove('quick-toggle');
}
```
## Manifest Data Declarations
Declare important data keys in the manifest.
```json
{
"data": [
{
"key": "soundboard:volume",
"scope": "client",
"storage": "local"
},
{
"key": "soundboard:favorites",
"scope": "server",
"storage": "serverData"
}
]
}
```

View File

@@ -0,0 +1,235 @@
---
sidebar_position: 11
---
# UI API
The UI API lets plugins add pages, settings pages, side panels, channel sections, actions, embed renderers, and controlled DOM mounts.
Prefer registered UI contributions over direct DOM mounting. Contribution APIs let Angular render the plugin UI when the matching app surface exists. Direct DOM mounting runs immediately and throws if the target selector is not present.
## Required Capabilities
| Method | Capability |
| --- | --- |
| `ui.registerAppPage(id, contribution)` | `ui.pages` |
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` |
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` |
| `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` |
| `ui.registerComposerAction(id, contribution)` | `ui.pages` |
| `ui.registerProfileAction(id, contribution)` | `ui.pages` |
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` |
| `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` |
| `ui.mountElement(id, request)` | `ui.dom` |
Every registration returns a disposable. Push it into `context.subscriptions`.
## App Page
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerAppPage('dashboard', {
label: 'Raid Dashboard',
path: '/plugins/example.raid-helper/dashboard',
render: () => {
const root = document.createElement('section');
root.innerHTML = '<h1>Raid Dashboard</h1><p>Tonight: dungeon practice.</p>';
return root;
}
}));
}
```
The page is hosted by `/plugins/:pluginId/:pageId`.
## Settings Page
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerSettingsPage('preferences', {
label: 'Raid Helper',
settingsKey: 'raid-helper',
order: 20,
render: () => {
const wrapper = document.createElement('section');
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = true;
label.append(checkbox, ' Enable ready-check reminders');
wrapper.append(label);
return wrapper;
}
}));
}
```
## Side Panel
Use `ui.registerSidePanel` for content that belongs in the server sidebar plugin area. Do not mount directly into `[data-testid="plugin-room-side-panel"]`; that host is route-specific and may not exist during plugin activation.
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerSidePanel('soundboard', {
label: 'Soundboard',
order: 10,
render: () => {
const panel = document.createElement('div');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Play chime';
button.onclick = () => context.api.media.playAudioClip({
url: 'https://cdn.example.com/chime.wav',
volume: 0.6
});
panel.append(button);
return panel;
}
}));
}
```
Capabilities required: `ui.sidePanel` and `media.playAudio`.
## Channel Section
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerChannelSection('events', {
label: 'Event Rooms',
type: 'custom',
order: 50
}));
}
```
## Composer Action
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerComposerAction('insert-standup', {
icon: 'ST',
label: 'Insert standup prompt',
run: (actionContext) => {
context.api.messages.send(
'Standup: yesterday I..., today I..., blocked by...',
actionContext.textChannel?.id
);
}
}));
}
```
Capabilities required: `ui.pages` and `messages.send`.
## Profile Action
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerProfileAction('wave', {
label: 'Wave',
run: (actionContext) => {
context.api.messages.send(`Waving at ${actionContext.user?.displayName ?? 'someone'}!`);
}
}));
}
```
## Toolbar Action
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerToolbarAction('open-dashboard', {
label: 'Raid Helper',
run: () => {
context.api.logger.info('Open the Raid Helper plugin page from /plugins/example.raid-helper/dashboard');
}
}));
}
```
## Embed Renderer
```js
export function activate(context) {
context.subscriptions.push(context.api.ui.registerEmbedRenderer('raid-card', {
embedType: 'raid.card',
render: (payload) => {
const card = document.createElement('article');
const title = document.createElement('h3');
const body = document.createElement('p');
title.textContent = payload?.title ?? 'Raid';
body.textContent = payload?.description ?? 'No description provided.';
card.append(title, body);
return card;
}
}));
}
```
Example message content for this embed:
```text
toju:embed:raid.card:{"title":"Friday Raid","description":"Meet in Lobby at 20:00."}
```
## DOM Mount
Use DOM mounting only when normal UI contribution points are not enough. `ui.mountElement` resolves its target immediately. If the target does not exist, plugin activation fails with `Plugin mount target not found: <selector>`.
Safe uses:
- Mounting a global overlay, badge, or modal into `body` during activation.
- Mounting into a route-specific element only after checking that element exists.
Avoid:
- Mounting sidebar content into `[data-testid="plugin-room-side-panel"]`. Use `ui.registerSidePanel`.
- Mounting chat content into `app-chat-messages` during activation without checking for the element.
```js
export function activate(context) {
const badge = document.createElement('div');
badge.textContent = 'Raid helper active';
badge.style.position = 'fixed';
badge.style.right = '16px';
badge.style.bottom = '16px';
badge.style.padding = '8px 10px';
badge.style.background = '#111827';
badge.style.color = 'white';
badge.style.borderRadius = '6px';
context.subscriptions.push(context.api.ui.mountElement('active-badge', {
target: 'body',
position: 'beforeend',
element: badge
}));
}
```
Route-specific mount example with a guard:
```js
export function activate(context) {
const target = document.querySelector('app-chat-messages');
if (!target) {
context.api.logger.warn('Chat messages host is not rendered yet; skipping chat mount');
return;
}
const banner = document.createElement('div');
banner.textContent = 'Raid helper active in this chat.';
context.subscriptions.push(context.api.ui.mountElement('chat-banner', {
target,
position: 'afterbegin',
element: banner
}));
}
```
The runtime tags plugin-owned DOM and removes it on unload, but plugins should still keep mounts minimal and accessible.

View File

@@ -0,0 +1,89 @@
---
sidebar_position: 3
---
# Users and Roles API
The users and roles APIs read known users, read room members, and perform moderation or role changes when granted.
## Required Capabilities
| Method | Capability |
| --- | --- |
| `users.getCurrent()` | `users.read` |
| `users.list()` | `users.read` |
| `users.readMembers()` | `users.read` |
| `users.setRole(userId, role)` | `roles.manage` |
| `users.kick(userId)` | `users.manage` |
| `users.ban(userId, reason?)` | `users.manage` |
| `roles.list()` | `roles.read` |
| `roles.setAssignments(assignments)` | `roles.manage` |
## Read Users
```js
export function activate(context) {
const currentUser = context.api.users.getCurrent();
const knownUsers = context.api.users.list();
const roomMembers = context.api.users.readMembers();
context.api.logger.info('Room user summary', {
currentUser: currentUser?.displayName,
knownUserCount: knownUsers.length,
memberCount: roomMembers.length
});
}
```
Example member data:
```json
[
{ "id": "member-1", "userId": "user-alice-01", "displayName": "Alice", "role": "admin" },
{ "id": "member-2", "userId": "user-muse-01", "displayName": "Muse", "role": "member" }
]
```
## Read Roles
```js
export function activate(context) {
const roles = context.api.roles.list();
context.api.logger.info('Available roles', roles.map((role) => ({
id: role.id,
name: role.name,
permissions: role.permissions
})));
}
```
## Set a User Role
```js
export function activate(context) {
context.api.users.setRole('user-muse-01', 'moderator');
}
```
## Replace Role Assignments
```js
export function activate(context) {
context.api.roles.setAssignments([
{ userId: 'user-alice-01', roleId: 'admin' },
{ userId: 'user-muse-01', roleId: 'moderator' }
]);
}
```
## Kick or Ban a User
```js
export function activate(context) {
context.api.users.kick('user-spam-01');
context.api.users.ban('user-spam-02', 'Repeated spam in support channels');
}
```
Moderation calls should normally be behind an explicit user action in plugin UI. Do not run destructive moderation automatically on activation.

View File

@@ -0,0 +1,50 @@
---
sidebar_position: 3
---
# Capabilities
Capabilities protect privileged app surfaces. A plugin must declare a capability in its manifest and the user must grant it before the runtime allows the corresponding API call.
| Capability | API areas | Notes |
| --- | --- | --- |
| `profile.read` | `profile.getCurrent()` | Reads the current user. |
| `profile.write` | `profile.update()`, `profile.updateAvatar()` | Updates local profile fields and avatar metadata. |
| `users.read` | `users.getCurrent()`, `users.list()`, `users.readMembers()` | Reads users and server members. |
| `users.manage` | `users.kick()`, `users.ban()`, `server.registerPluginUser()` | Can create plugin users and moderate members. |
| `roles.read` | `roles.list()` | Reads server roles. |
| `roles.manage` | `roles.setAssignments()`, `users.setRole()` | Changes role assignments or user roles. |
| `messages.read` | `messages.readCurrent()`, message bus latest snapshots | Reads current channel messages. |
| `messages.send` | `messages.send()`, `messages.sendAsPluginUser()` | Sends messages as the current user or registered plugin user. |
| `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. |
| `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. |
| `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. |
| `messages.sync` | `messages.sync()` | Syncs message arrays into client state. |
| `channels.read` | `channels.list()`, `channels.select()` | Reads and selects channels. |
| `channels.manage` | `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. |
| `server.read` | `server.getCurrent()` | Reads active server. |
| `server.manage` | `server.updatePermissions()`, `server.updateSettings()` | Updates server permissions or settings. |
| `p2p.data` | `p2p.connectedPeers()`, `p2p.broadcastData()`, `p2p.sendData()` | Uses plugin peer data paths. |
| `p2p.media` | Reserved peer media features. | Included for media-facing plugins. |
| `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. |
| `media.addAudioStream` | `media.addCustomAudioStream()` | Adds a custom stream to voice handling. |
| `media.addVideoStream` | `media.addCustomVideoStream()` | Registers custom video stream contribution. |
| `audio.volume` | `media.setInputVolume()`, `media.setOutputVolume()` | Adjusts local voice volume. |
| `audio.effects` | Reserved audio effect features. | Included for audio processing plugins. |
| `ui.settings` | `ui.registerSettingsPage()` | Adds settings pages. |
| `ui.pages` | `ui.registerAppPage()`, `ui.registerComposerAction()`, `ui.registerProfileAction()`, `ui.registerToolbarAction()` | Adds app pages and actions. |
| `ui.sidePanel` | `ui.registerSidePanel()` | Adds side panels. |
| `ui.channelsSection` | `ui.registerChannelSection()` | Adds channel sections. |
| `ui.embeds` | `ui.registerEmbedRenderer()` | Renders custom embeds. |
| `ui.dom` | `ui.mountElement()` | Mounts plugin-owned DOM into app targets. |
| `storage.local` | `storage.*`, `clientData.*` | Reads and writes plugin-local data. |
| `storage.serverData.read` | `serverData.read()` | Reads local per-user/per-server plugin data. |
| `storage.serverData.write` | `serverData.write()`, `serverData.remove()` | Writes or removes local per-user/per-server plugin data. |
| `events.server.publish` | `events.publishServer()` | Publishes declared server plugin events. |
| `events.server.subscribe` | `events.subscribeServer()` | Subscribes to declared server plugin events. |
| `events.p2p.publish` | `events.publishP2p()`, `messageBus.publish()`, `messageBus.sendLatestMessages()` | Publishes declared P2P/plugin bus events. |
| `events.p2p.subscribe` | `events.subscribeP2p()`, `messageBus.subscribe()` | Subscribes to declared P2P/plugin bus events. |
## Recommended Practice
Request the fewest capabilities possible. Separate broad features into optional plugin modules when a single plugin would otherwise need many unrelated grants.

View File

@@ -0,0 +1,106 @@
---
sidebar_position: 1
---
# Create a Plugin
MetoYou plugins are browser-safe ES modules loaded by the Angular renderer. A plugin receives a frozen `TojuClientPluginApi`, declares every privileged capability in its manifest, and registers cleanup work through disposables.
## Folder Layout
A local desktop plugin is discovered from an immediate child folder under the app data `plugins` directory.
```text
my-plugin/
toju-plugin.json
main.js
README.md
icon.svg
```
The manifest file can be named `toju-plugin.json` or `plugin.json`. Entrypoints and readmes must stay inside the plugin folder.
## Minimal Manifest
```json
{
"schemaVersion": 1,
"id": "example.hello-world",
"title": "Hello World",
"description": "Adds a toolbar action that sends a message.",
"version": "1.0.0",
"kind": "client",
"scope": "client",
"apiVersion": "1.0.0",
"compatibility": {
"minimumTojuVersion": "1.0.0"
},
"entrypoint": "./main.js",
"capabilities": ["messages.send", "ui.pages"]
}
```
## Entrypoint
```js
export function activate(context) {
const { api } = context;
api.logger.info('Hello World activated');
const disposable = api.ui.registerToolbarAction('hello', {
label: 'Hello',
run: () => api.messages.send('Hello from my plugin')
});
context.subscriptions.push(disposable);
}
export function ready(context) {
context.api.logger.info('All ready plugins have loaded');
}
export function deactivate(context) {
context.api.logger.info('Hello World deactivated');
}
```
## Lifecycle Hooks
| Hook | When it runs | Use it for |
| --- | --- | --- |
| `activate(context)` | During explicit plugin activation. | Register UI, subscribe to events, initialize state. |
| `ready(context)` | After the load-order pass has activated ready plugins. | Cross-plugin coordination that needs other plugins loaded. |
| `deactivate(context)` | During unload or reload. | Flush state and log shutdown. Disposables are also cleaned up by the host. |
| `onPluginDataChanged(context, event)` | When plugin data changes are observed. | React to plugin-scoped persistence changes. |
| `onServerRequirementsChanged(context, snapshot)` | When server plugin requirements change. | Adapt to required, optional, blocked, or incompatible server plugins. |
## Cleanup
Every API registration returns a disposable. Push it into `context.subscriptions`.
```js
const subscription = api.messageBus.subscribe({
topic: 'poll:votes',
handler: (event) => api.logger.info('vote received', event.payload)
});
context.subscriptions.push(subscription);
```
The plugin host disposes subscriptions in reverse order when the plugin unloads.
## Capability Grants
A plugin can only call privileged APIs after the matching capability is declared in the manifest and granted by the user. Keep the manifest narrow. For example, a plugin that only adds a settings page does not need message or user management capabilities.
## Testing Locally
1. Create the plugin folder in the desktop plugins directory.
2. Open the Plugin Manager.
3. Register or refresh local plugins.
4. Grant required capabilities.
5. Activate the plugin.
6. Inspect plugin logs in the manager.
For broad API examples, compare against the E2E fixture plugin under `toju-app/public/plugins/e2e-all-api/`.

View File

@@ -0,0 +1,204 @@
---
sidebar_position: 5
---
# Examples
## Toolbar Message Plugin
`toju-plugin.json`
```json
{
"schemaVersion": 1,
"id": "example.toolbar-message",
"title": "Toolbar Message",
"description": "Adds a toolbar action that sends a reusable message.",
"version": "1.0.0",
"kind": "client",
"scope": "client",
"apiVersion": "1.0.0",
"compatibility": {
"minimumTojuVersion": "1.0.0",
"verifiedTojuVersion": "1.0.0"
},
"entrypoint": "./main.js",
"capabilities": ["messages.send", "ui.pages"]
}
```
`main.js`
```js
export function activate(context) {
const { api } = context;
context.subscriptions.push(api.ui.registerToolbarAction('standup-message', {
label: 'Standup',
run: () => api.messages.send('Standup: yesterday, today, blocked')
}));
}
```
## Settings Page Plugin
```json
{
"schemaVersion": 1,
"id": "example.settings-page",
"title": "Settings Page Example",
"description": "Adds a plugin settings page and stores a local preference.",
"version": "1.0.0",
"kind": "client",
"apiVersion": "1.0.0",
"compatibility": { "minimumTojuVersion": "1.0.0" },
"entrypoint": "./main.js",
"capabilities": ["ui.settings", "storage.local"],
"settings": {
"type": "object",
"properties": {
"enabled": { "type": "boolean", "default": true }
}
}
}
```
```js
export function activate(context) {
const { api } = context;
context.subscriptions.push(api.ui.registerSettingsPage('preferences', {
label: 'Example Preferences',
render: () => {
const root = document.createElement('section');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Remember preference';
button.onclick = () => api.storage.set('enabled', true);
root.append(button);
return root;
}
}));
}
```
## Server-Scoped Soundboard
A server-scoped plugin can be installed as a server requirement and auto-installed for server members when marked required.
```json
{
"schemaVersion": 1,
"id": "example.soundboard",
"title": "Server Soundboard",
"description": "Adds a soundboard side panel and announces played sounds.",
"version": "1.0.0",
"kind": "client",
"scope": "server",
"apiVersion": "1.0.0",
"compatibility": { "minimumTojuVersion": "1.0.0" },
"entrypoint": "./main.js",
"capabilities": [
"server.read",
"users.manage",
"ui.sidePanel",
"media.playAudio",
"messages.send"
],
"pluginUser": {
"displayName": "Soundboard",
"label": "Audio helper"
}
}
```
```js
export function activate(context) {
const { api } = context;
const botId = api.server.registerPluginUser({
id: 'soundboard-bot',
displayName: 'Soundboard'
});
context.subscriptions.push(api.ui.registerSidePanel('sounds', {
label: 'Soundboard',
render: () => {
const panel = document.createElement('div');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Play chime';
button.onclick = async () => {
await api.media.playAudioClip({ url: './chime.wav', volume: 0.7 });
api.messages.sendAsPluginUser({ pluginUserId: botId, content: 'Played chime' });
};
panel.append(button);
return panel;
}
}));
}
```
## Message Bus Plugin
```json
{
"schemaVersion": 1,
"id": "example.poll-bus",
"title": "Poll Bus",
"description": "Uses the plugin message bus for lightweight P2P poll votes.",
"version": "1.0.0",
"kind": "client",
"apiVersion": "1.0.0",
"compatibility": { "minimumTojuVersion": "1.0.0" },
"entrypoint": "./main.js",
"capabilities": ["events.p2p.publish", "events.p2p.subscribe", "messages.read"]
}
```
```js
export function activate(context) {
const { api } = context;
context.subscriptions.push(api.messageBus.subscribe({
topic: 'poll:votes',
replayLatest: true,
latestMessageLimit: 20,
handler: (event) => api.logger.info('Vote received', event.payload)
}));
api.messageBus.publish({
topic: 'poll:votes',
payload: { option: 'A' },
includeLatestMessages: true,
includeSelf: true,
latestMessageLimit: 20
});
}
```
## Custom DOM Mount
Use `ui.dom` sparingly and cleanly. The runtime tags mounted elements with plugin ownership metadata and removes remaining mounted elements when the plugin unloads.
```js
export function activate(context) {
const badge = document.createElement('div');
badge.textContent = 'Plugin active';
badge.style.position = 'absolute';
badge.style.right = '1rem';
badge.style.bottom = '1rem';
context.subscriptions.push(context.api.ui.mountElement('active-badge', {
target: 'body',
element: badge
}));
}
```
## All-API Fixture
The repo includes an E2E fixture at `toju-app/public/plugins/e2e-all-api/`. It intentionally calls every public plugin API surface so Playwright coverage can validate the runtime. Use it as a compatibility reference, not as the minimal style for production plugins.

View File

@@ -0,0 +1,164 @@
---
sidebar_position: 2
---
# Manifest Model
The manifest is the source of truth for plugin identity, compatibility, runtime shape, capabilities, data, events, UI hints, and distribution metadata.
```ts
type TojuPluginInstallScope = 'client' | 'server';
type PluginEventDirection = 'clientToServer' | 'serverRelay' | 'p2pHint';
type PluginEventScope = 'server' | 'channel' | 'user' | 'plugin';
type PluginCapabilityId =
| 'profile.read'
| 'profile.write'
| 'users.read'
| 'users.manage'
| 'roles.read'
| 'roles.manage'
| 'messages.read'
| 'messages.send'
| 'messages.editOwn'
| 'messages.deleteOwn'
| 'messages.moderate'
| 'messages.sync'
| 'channels.read'
| 'channels.manage'
| 'server.read'
| 'server.manage'
| 'p2p.data'
| 'p2p.media'
| 'media.playAudio'
| 'media.addAudioStream'
| 'media.addVideoStream'
| 'audio.volume'
| 'audio.effects'
| 'ui.settings'
| 'ui.pages'
| 'ui.sidePanel'
| 'ui.channelsSection'
| 'ui.embeds'
| 'ui.dom'
| 'storage.local'
| 'storage.serverData.read'
| 'storage.serverData.write'
| 'events.server.publish'
| 'events.server.subscribe'
| 'events.p2p.publish'
| 'events.p2p.subscribe';
interface TojuPluginManifest {
schemaVersion: 1;
id: string;
title: string;
description: string;
version: string;
kind: 'client' | 'library';
scope?: TojuPluginInstallScope;
apiVersion: string;
compatibility: {
minimumTojuVersion: string;
maximumTojuVersion?: string;
verifiedTojuVersion?: string;
};
entrypoint?: string;
bundle?: {
url: string;
entrypoint?: string;
};
readme?: string;
homepage?: string;
bugs?: string;
changelog?: string;
license?: string;
authors?: {
name: string;
email?: string;
url?: string;
}[];
capabilities?: PluginCapabilityId[];
events?: {
eventName: string;
direction: PluginEventDirection;
scope: PluginEventScope;
maxPayloadBytes?: number;
schema?: string;
}[];
data?: {
key: string;
schema?: string;
scope: string;
storage: 'local' | 'serverData';
}[];
relationships?: {
after?: string[];
before?: string[];
conflicts?: string[];
optional?: { id: string; versionRange?: string }[];
requires?: { id: string; versionRange?: string }[];
};
load?: {
priority?: 'bootstrap' | 'high' | 'default' | 'low';
};
pluginUser?: {
avatar?: string;
displayName: string;
label?: string;
};
settings?: Record<string, unknown>;
ui?: Record<string, unknown>;
}
```
## Required Fields
| Field | Meaning |
| --- | --- |
| `schemaVersion` | Manifest schema version. Currently `1`. |
| `id` | Stable plugin id. Use a reverse-DNS or package-style id. |
| `title` | Human-readable plugin name. |
| `description` | Short explanation shown in plugin UI. |
| `version` | Plugin version. |
| `kind` | `client` for runtime plugins, `library` for shared dependency-style entries. |
| `apiVersion` | Plugin API version expected by the plugin. |
| `compatibility.minimumTojuVersion` | Oldest app version the plugin supports. |
## Scope
`scope: "client"` installs the plugin for the current client. Omit `scope` for the same behavior.
`scope: "server"` marks a plugin as server-scoped. Server-scoped store entries can be installed to a chat server as requirements. Required server plugins are auto-installed for members when that server opens; optional requirements stay listed but do not auto-install.
When a user installs a server-scoped plugin into the server they are currently viewing, MetoYou enables that plugin id locally and activates the plugin immediately after the local manifest is registered. Installing a server-scoped plugin for another server records the activation preference so it activates when that server is opened.
## Entrypoint and Bundle
Use `entrypoint` for a browser-resolvable module relative to the manifest. Use `bundle.url` when publishing a cached browser bundle through a plugin source manifest. Desktop installs cache bundle files into app data and load the cached manifest afterward.
## Events
Every server or P2P plugin event should be declared before it is published or subscribed to.
```json
{
"events": [
{
"eventName": "poll:vote",
"direction": "p2pHint",
"scope": "channel",
"maxPayloadBytes": 2048
}
]
}
```
## Data Declarations
Use `data` to document plugin-owned data keys and intended storage.
- `local` maps to client-local plugin data.
- `serverData` maps to local per-user/per-server plugin data.
Signal server HTTP persistence for arbitrary plugin data is disabled by design.

View File

@@ -0,0 +1,66 @@
---
sidebar_position: 1
---
# First Steps
MetoYou is a chat app for servers, text conversations, direct messages, and live voice. You do not need to understand the technical parts to use it.
## Main Words
| Word | Meaning |
| --- | --- |
| Server | A shared space for a community, team, or group. |
| Text channel | A named chat room inside a server. Messages stay in that channel. |
| Voice channel | A named live room inside a server. Join it when you want to talk, share camera, or share screen. |
| Direct message | A private conversation outside a server channel. |
| Plugin | An add-on that can add buttons, panels, tools, integrations, or server-specific features. |
## Sign In
1. Open MetoYou.
2. Sign in with your username and password.
3. If you use more than one signaling server, choose the server endpoint that owns your account.
A signaling server handles accounts, server discovery, membership, and connection setup. In normal use you can think of it as the place MetoYou checks when you log in and join servers.
## Find a Server
1. Open the server search page.
2. Search by server name or browse the available list.
3. Select a server.
4. Join directly if it is public, enter the password if it is protected, or use an invite link if someone sent you one.
After joining, the server appears in the vertical server rail on the left. Click a server icon there to switch servers.
## Read and Send Messages
1. Click a server in the left rail.
2. Pick a text channel under **Text Channels**.
3. Type in the composer at the bottom of the chat.
4. Press Enter or use the send button.
Text channels keep different topics separate. For example, a server might have `general`, `announcements`, and `support` as separate text channels.
## Start Talking
1. Open a server.
2. Pick a voice channel under **Voice Channels**.
3. Click the voice channel to join.
4. Use the voice controls to mute, deafen, start camera, share screen, or leave.
Voice is live. Text messages are written chat. They can happen at the same time, but they are different channel types.
## Use Direct Messages
Direct messages are one-to-one conversations. They are separate from server text channels, so they do not depend on which server you are viewing.
## Open Settings
Settings contain account, voice, plugin, server, desktop, update, local API, theme, and data controls. Desktop users can also manage local data import/export and local documentation/API hosting.
## Install Plugins
Plugins are installed from the Plugin Store or Plugin Manager. Some plugins are global client plugins. Other plugins are server-scoped and only apply to a specific server.
See [Plugins for Users](./plugins.md) for the full non-technical plugin guide.

View File

@@ -0,0 +1,82 @@
---
sidebar_position: 5
---
# Plugins for Users
Plugins add features to MetoYou. They can add pages, buttons, panels, settings, sounds, message tools, custom embeds, or server-specific behavior.
## Types of Plugins
| Type | What it means |
| --- | --- |
| Client plugin | Installed for your app. It follows you across servers when active. |
| Server plugin | Installed for a specific server. It may be required, recommended, optional, blocked, or incompatible. |
| Library plugin | Shared plugin code used by other plugins. It is not normally something users interact with directly. |
## Install from the Plugin Store
1. Open the Plugin Store from the title bar or Settings.
2. Browse or search available plugins.
3. Open the plugin details.
4. Read the description, version, source, and capability list.
5. Choose install.
6. Review and grant only the capabilities you trust.
7. Activate the plugin.
Server-scoped plugins installed to the server you are currently viewing are enabled and activated automatically after install, so their panels, actions, or embeds can appear immediately.
## Install a Local Plugin
Desktop builds can discover local plugin folders from the app data plugins directory.
1. Put the plugin folder in the desktop plugins directory.
2. Open Settings.
3. Open the Plugin Manager.
4. Refresh or register local plugins.
5. Grant capabilities and activate the plugin.
## Server Plugin Prompts
When a server uses plugins, MetoYou may show a prompt.
| Status | Meaning |
| --- | --- |
| Required | You must install the plugin to join or continue using that server. |
| Recommended | The server suggests the plugin, but you can choose. |
| Optional | The plugin is available for the server, but not required. |
| Blocked | The server marks the plugin as not allowed. |
| Incompatible | The plugin version does not work with your app version or the server requirement. |
Required plugins are still installed locally on your device. The signaling server stores requirement metadata only; it does not run plugin code.
## Capability Grants
Plugins must ask for capabilities before using sensitive features.
Examples:
| Capability area | Why a plugin might ask |
| --- | --- |
| Messages | Send messages, read current messages, moderate messages, or render embeds. |
| Users and roles | Read member lists, create plugin users, or manage users. |
| Voice and media | Play audio, add an audio stream, add a video stream, or adjust volume. |
| UI | Add pages, settings pages, side panels, toolbar buttons, or DOM elements. |
| Storage | Save plugin preferences locally or per server. |
Only grant capabilities to plugins you trust.
## Manage Plugins
The Plugin Manager lets you:
- activate, deactivate, reload, or unload plugins;
- grant or revoke capabilities;
- inspect plugin logs;
- see plugin UI contribution counts;
- review server plugin requirements;
- uninstall plugins.
## Plugin Safety Notes
Plugins are browser-safe JavaScript modules loaded by the client. They do not run on the signaling server. A plugin can only call privileged MetoYou APIs when its manifest declares the capability and you grant it.

View File

@@ -0,0 +1,65 @@
---
sidebar_position: 2
---
# Servers and Channels
A server is the main shared space in MetoYou. Servers contain members, channels, permissions, optional plugins, and server settings.
## Server Rail
The server rail is the vertical list of servers on the left side of the app.
- Click a server icon to open it.
- Use the add/search control to find or join more servers.
- A badge can show unread activity.
- Server context actions can include invite, leave, or server settings depending on your permissions.
## Text Channels
Text channels are written conversations. Each text channel has its own message list.
Common examples:
| Channel | Use |
| --- | --- |
| `general` | Everyday chat. |
| `announcements` | Updates from owners or admins. |
| `support` | Help requests. |
| `clips` | Shared media or links. |
Messages, replies, reactions, attachments, GIFs, typing indicators, and plugin-created messages are scoped to the active text channel.
## Voice Channels
Voice channels are live spaces. Joining a voice channel connects your microphone and lets you use camera or screen sharing when enabled.
Voice channel examples:
| Channel | Use |
| --- | --- |
| `Lobby` | Casual drop-in voice. |
| `Gaming` | In-game voice. |
| `Meeting` | Focused calls. |
| `Support Room` | Live help. |
## Text Channels vs Voice Channels
| Text channel | Voice channel |
| --- | --- |
| Written messages. | Live audio and media. |
| You can read later. | You join and leave in real time. |
| Uses the message composer. | Uses voice controls. |
| Good for searchable discussions. | Good for conversations, calls, screen shares, and quick coordination. |
## Server Members
The member list shows people known to the server. Online members appear separately from offline members. Depending on permissions, owners, admins, or moderators can move users between voice channels, kick users, ban users, or change roles.
## Invites
Invite links help other users join a server. If a server is private or password-protected, the invite or password controls who can enter.
## Server Plugins
A server can recommend or require plugins. Required server plugins may block joining until you choose whether to install them. Optional and recommended plugins can be skipped.

View File

@@ -0,0 +1,33 @@
---
sidebar_position: 6
---
# Settings and Data
Settings control the app, voice, plugins, servers, themes, updates, local APIs, and desktop behavior.
## Common Settings
| Area | What you can manage |
| --- | --- |
| Account | Current profile, display details, and avatar metadata. |
| Voice | Devices, volumes, bitrate, latency, noise reduction, screen share preferences. |
| Plugins | Installed plugins, capability grants, plugin logs, and plugin store sources. |
| Server | Server details, channels, roles, moderation, plugin requirements, and member controls. |
| Theme | App colors and visual preferences. |
| Desktop | Tray behavior, auto-start, hardware acceleration, updates, and local data tools. |
| Local API | Local HTTP server, API docs, Docusaurus docs, and allowed signaling servers. |
## Local Data
Desktop MetoYou stores local app data on your device. That can include rooms, messages, users, plugin data, settings, and metadata. The desktop settings include data import/export tools.
## Local API and Documentation Hosting
The desktop app can start a local HTTP server. It is off by default. When enabled, it can serve:
- Local REST API endpoints under `/api/...`;
- Scalar REST API docs at `/docs`;
- this Docusaurus site at `/docusaurus`.
Authentication for protected local API routes uses a local bearer token. Login is checked against an allowed signaling server that you configure in settings.

View File

@@ -0,0 +1,37 @@
---
sidebar_position: 3
---
# Text and Direct Messages
Text channels and direct messages both use written chat, but they are meant for different situations.
## Text Channels
Text channels belong to a server. Everyone with access to that server and channel can participate.
You can use text channels to:
- send normal messages;
- edit or delete your own messages when allowed;
- react to messages;
- send attachments;
- browse and send GIFs when available;
- see typing indicators;
- read synced message history stored on your device.
## Direct Messages
Direct messages are private conversations outside a server channel. Use them when a message is meant for one person instead of the server.
## Attachments and Media
Attachments can appear as files, images, audio, or video depending on the file type and what the app can preview. If an image or link cannot load directly, the app can use fallback paths where available.
## Message Sync
MetoYou stores messages locally and syncs recent messages with peers when connections are available. If you were offline, messages may appear after peers reconnect and exchange their recent message lists.
## Plugin Messages
Some plugins can send messages, create bot-style plugin users, render custom embeds, or add composer buttons. MetoYou asks for plugin capability grants before plugins can use privileged message features.

View File

@@ -0,0 +1,73 @@
---
sidebar_position: 4
---
# Voice Channels and Calls
Voice channels are live rooms inside a server. Join one when you want to talk, share camera, or share your screen.
## Join a Voice Channel
1. Open the server from the left server rail.
2. Find **Voice Channels** in the server side panel.
3. Click the voice channel you want to join.
4. Allow microphone access if your system asks.
5. Use the voice controls to manage your call.
When you join, other users in the same voice channel can hear you unless you are muted. Users in other voice channels are not part of your live voice room.
## Voice Controls
The voice controls can include:
| Control | What it does |
| --- | --- |
| Mute microphone | Stops sending your microphone audio. |
| Deafen | Stops playback and usually mutes your microphone too. |
| Camera | Starts or stops webcam video. |
| Screen share | Shares a screen or window. |
| Settings | Opens voice device and quality settings. |
| Leave | Disconnects from the voice channel. |
## Screen Sharing
1. Join a voice channel.
2. Click screen share.
3. Choose a screen or window.
4. Choose whether to include system audio when available.
5. Stop sharing from the voice controls when done.
The screen share picker can show screens and windows. Desktop audio support depends on operating system support and the selected source.
## Voice Workspace
When someone shares camera or screen, the voice workspace can expand into a larger media area. It can show focused streams, a grid of streams, or a minimized mini-window.
## Floating Voice Controls
If you navigate away from the server while still connected to voice, MetoYou can show floating voice controls. Use them to return to the voice server or leave the call.
## Voice Settings
Voice settings can include:
- input device;
- output device;
- input volume;
- output volume;
- audio bitrate;
- latency profile;
- noise reduction;
- screen share quality;
- system audio preference.
## Troubleshooting
| Problem | Try this |
| --- | --- |
| Nobody hears you | Check mute, input device, system microphone permission, and input volume. |
| You hear nobody | Check deafen, output device, output volume, and whether others are in the same voice channel. |
| Screen share is missing | Check desktop permissions and try a different screen or window. |
| Voice drops after switching servers | Return to the server with the active voice session or leave and rejoin the voice channel. |
Voice and screen sharing use peer-to-peer WebRTC media. The signaling server helps users connect, but the media itself travels through peer connections.

View File

@@ -0,0 +1,56 @@
---
sidebar_position: 2
---
# Using MetoYou
## Sign In
MetoYou signs in through a signaling server. The signaling server validates the user account, coordinates server membership, relays selected realtime messages, and helps peers establish WebRTC connections.
For the desktop Local API, the same signaling server allow-list is used before local bearer tokens can be issued. This keeps local automation tied to servers you explicitly trust.
## Find or Join Servers
Use the server search flow to find known servers. A server can be public, private, or password-protected depending on its settings. Invite links can be created from the title bar menu while a server is active.
A server contains:
- basic profile information such as name, topic, description, privacy, and maximum users;
- text channels;
- voice or custom channel sections;
- roles and permissions;
- members and voice state;
- optional server-scoped plugin requirements.
## Text Channels and Messages
Text channels are selected inside the active server. Messages are persisted locally by the client and synchronized through realtime events while connected. Plugins with the relevant capabilities can read, send, edit, delete, moderate, or sync messages.
Direct messages use the same shell but are not part of a room channel context.
## Voice, Video, and Screen Sharing
Voice and media are peer-to-peer. The signaling server coordinates connection setup, while media streams travel through WebRTC peer connections.
Desktop builds include platform integrations such as Linux display-server detection and optional monitor audio routing for screen sharing. Plugin media APIs can contribute custom audio or video streams when the user grants the necessary capabilities.
## Plugins
Open the Plugin Store from the title bar package button or menu. The plugin manager separates global client plugins from server-scoped plugins. Installed plugins can be activated, reloaded, unloaded, disabled, inspected for logs, and granted capabilities.
Plugins are explicit runtime modules. MetoYou loads browser-safe ES modules, passes a frozen API object, and cleans up registered disposables when a plugin unloads.
## Desktop Settings
Desktop settings cover:
- auto-start and close-to-tray behavior;
- hardware acceleration and Linux VA-API video encode options;
- update manifests and target update versions;
- local HTTP API hosting;
- Scalar API documentation;
- Docusaurus app/plugin documentation;
- allowed signaling servers for local API authentication;
- local plugin discovery and store sources;
- themes and user data import/export.

View File

@@ -0,0 +1,71 @@
import type { Config } from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic';
const config: Config = {
title: 'MetoYou Docs',
tagline: 'Desktop chat, local APIs, and plugin development',
url: 'http://127.0.0.1',
baseUrl: '/docusaurus/',
organizationName: 'metoyou',
projectName: 'metoyou',
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
i18n: {
defaultLocale: 'en',
locales: ['en']
},
presets: [
[
'classic',
{
docs: {
routeBasePath: '/',
sidebarPath: './sidebars.ts'
},
blog: false,
theme: {
customCss: './src/css/custom.css'
}
} satisfies Preset.Options
]
],
themeConfig: {
navbar: {
title: 'MetoYou Docs',
items: [
{ type: 'docSidebar', sidebarId: 'mainSidebar', position: 'left', label: 'Guides' },
{ to: '/user-guide/first-steps', label: 'User Guide', position: 'left' },
{ to: '/developer/contributing', label: 'Developer Guide', position: 'left' },
{ to: '/plugin-development/create-a-plugin', label: 'Plugin Guide', position: 'left' },
{ to: '/developer/rest-api', label: 'REST API', position: 'left' }
]
},
footer: {
style: 'dark',
links: [
{
title: 'Docs',
items: [
{ label: 'First Steps', to: '/user-guide/first-steps' },
{ label: 'Voice Channels', to: '/user-guide/voice-channels' },
{ label: 'Plugins for Users', to: '/user-guide/plugins' },
{ label: 'Contributing', to: '/developer/contributing' },
{ label: 'Create a Plugin', to: '/plugin-development/create-a-plugin' },
{ label: 'Plugin API Reference', to: '/plugin-development/api-reference' },
{ label: 'Local REST API', to: '/developer/rest-api' }
]
}
],
copyright: 'MetoYou local documentation. Built with Docusaurus.'
},
prism: {
additionalLanguages: [
'bash',
'json',
'typescript'
]
}
} satisfies Preset.ThemeConfig
};
export default config;

18490
docs-site/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
docs-site/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "metoyou-docs",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "docusaurus start --host 127.0.0.1",
"build": "docusaurus build",
"serve": "docusaurus serve --host 127.0.0.1"
},
"dependencies": {
"@docusaurus/core": "3.10.0",
"@docusaurus/preset-classic": "3.10.0",
"@mdx-js/react": "^3.1.1",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.1",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.0",
"@docusaurus/tsconfig": "3.10.0",
"@docusaurus/types": "3.10.0",
"typescript": "~5.9.2"
},
"overrides": {
"webpack": "5.101.3"
}
}

62
docs-site/sidebars.ts Normal file
View File

@@ -0,0 +1,62 @@
import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';
const sidebars: SidebarsConfig = {
mainSidebar: [
'intro',
{
type: 'category',
label: 'User Guide',
items: [
'user-guide/first-steps',
'user-guide/servers-and-channels',
'user-guide/text-and-direct-messages',
'user-guide/voice-channels',
'user-guide/plugins',
'user-guide/settings',
'using-metoyou'
]
},
{
type: 'category',
label: 'Developer Guide',
items: [
'developer/contributing',
'developer/docusaurus-site',
'developer/dom-structure',
'developer/rest-api',
'developer/llm-plugin-builder-guide',
'desktop-and-local-api'
]
},
{
type: 'category',
label: 'Plugin Development',
items: [
'plugin-development/create-a-plugin',
'plugin-development/manifest',
'plugin-development/capabilities',
'plugin-development/api-reference',
{
type: 'category',
label: 'Plugin API Examples',
items: [
'plugin-development/api/context-and-logging',
'plugin-development/api/profile',
'plugin-development/api/users-and-roles',
'plugin-development/api/server',
'plugin-development/api/channels',
'plugin-development/api/messages-and-typing',
'plugin-development/api/events',
'plugin-development/api/message-bus',
'plugin-development/api/p2p-and-media',
'plugin-development/api/storage',
'plugin-development/api/ui'
]
},
'plugin-development/examples'
]
}
]
};
export default sidebars;

View File

@@ -0,0 +1,40 @@
:root {
--ifm-color-primary: #2f9ab2;
--ifm-color-primary-dark: #2a8ba0;
--ifm-color-primary-darker: #287f94;
--ifm-color-primary-darkest: #216979;
--ifm-color-primary-light: #36abc5;
--ifm-color-primary-lighter: #43b4ce;
--ifm-color-primary-lightest: #6cc5d8;
--ifm-code-font-size: 92%;
--ifm-border-radius: 6px;
}
[data-theme='dark'] {
--ifm-background-color: #101318;
--ifm-background-surface-color: #171b22;
--ifm-navbar-background-color: #12161d;
--ifm-footer-background-color: #0b0e13;
--ifm-color-primary: #58c4dc;
--ifm-color-primary-dark: #36b7d3;
--ifm-color-primary-darker: #27aeca;
--ifm-color-primary-darkest: #208fa6;
--ifm-color-primary-light: #79d1e3;
--ifm-color-primary-lighter: #8bd7e7;
--ifm-color-primary-lightest: #bde9f1;
}
.hero--primary {
--ifm-hero-background-color: #151b24;
--ifm-hero-text-color: #f6f8fb;
}
.theme-doc-markdown table code,
.theme-doc-markdown li code,
.theme-doc-markdown p code {
border: 1px solid var(--ifm-color-emphasis-300);
}
.plugin-api-table td:first-child {
white-space: nowrap;
}

36
e2e/README.md Normal file
View File

@@ -0,0 +1,36 @@
# End-to-End Tests
Playwright suite for the MetoYou / Toju product client. The tests exercise browser flows such as authentication, chat, voice, screen sharing, and settings with reusable page objects and helpers.
## Commands
Run these from the repository root:
- `npm run test:e2e` runs the full Playwright suite.
- `npm run test:e2e:ui` opens Playwright UI mode.
- `npm run test:e2e:debug` runs the suite in debug mode.
- `npm run test:e2e:report` opens the HTML report in `test-results/html-report`.
You can also run `npx playwright test` from `e2e/` directly.
## Runtime
- `playwright.config.ts` starts `cd ../toju-app && npx ng serve` as the test web server.
- The suite targets `http://localhost:4200`.
- Tests currently run with a single Chromium worker.
- The browser launches with fake media-device flags and grants microphone/camera permissions.
- Artifacts are written to `../test-results/artifacts`, and the HTML report is written to `../test-results/html-report`.
## Structure
| Path | Description |
| --- | --- |
| `tests/` | Test specs grouped by feature area such as `auth/`, `chat/`, `voice/`, `screen-share/`, and `settings/` |
| `pages/` | Reusable Playwright page objects |
| `helpers/` | Test helpers, fake-server utilities, and WebRTC helpers |
| `fixtures/` | Shared test fixtures |
## Notes
- The suite is product-client focused; it does not currently spin up the marketing website.
- Keep reusable browser flows in `pages/` and cross-test utilities in `helpers/`.

4
e2e/fixtures/base.ts Normal file
View File

@@ -0,0 +1,4 @@
import { test as base } from '@playwright/test';
export const test = base;
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,74 @@
import {
test as base,
chromium,
type Page,
type BrowserContext,
type Browser
} from '@playwright/test';
import { join } from 'node:path';
import { installTestServerEndpoint } from '../helpers/seed-test-endpoint';
import { startTestServer, type TestServerHandle } from '../helpers/test-server';
export interface Client {
page: Page;
context: BrowserContext;
}
interface MultiClientFixture {
createClient: () => Promise<Client>;
testServer: TestServerHandle;
}
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}`,
'--autoplay-policy=no-user-gesture-required'
];
export const test = base.extend<MultiClientFixture>({
testServer: async ({ playwright: _playwright }, use: (testServer: TestServerHandle) => Promise<void>) => {
const testServer = await startTestServer();
await use(testServer);
await testServer.stop();
},
createClient: async ({ testServer }, use) => {
const browsers: Browser[] = [];
const clients: 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({
permissions: ['microphone', 'camera'],
baseURL: 'http://localhost:4200'
});
await installTestServerEndpoint(context, testServer.port);
const page = await context.newPage();
clients.push({ page, context });
return { page, context };
};
await use(factory);
for (const client of clients) {
await client.context.close();
}
for (const browser of browsers) {
await browser.close();
}
}
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,3 @@
# E2E Plugin API Fixture
This plugin is intentionally tiny. Tests use its manifest to exercise plugin discovery, server support metadata, server data, and plugin event relay APIs without executing plugin code.

View File

@@ -0,0 +1,6 @@
export default {
id: 'e2e.plugin-api',
activate(api) {
api?.logger?.info?.('E2E Plugin API Fixture activated');
}
};

View File

@@ -0,0 +1,49 @@
{
"apiVersion": "1.0.0",
"capabilities": [
"storage.serverData.read",
"storage.serverData.write",
"events.server.publish",
"events.server.subscribe",
"events.p2p.publish",
"events.p2p.subscribe"
],
"compatibility": {
"minimumTojuVersion": "1.0.0",
"verifiedTojuVersion": "1.0.0"
},
"data": [
{
"key": "settings",
"scope": "server",
"storage": "serverData"
},
{
"key": "presence",
"scope": "user",
"storage": "serverData"
}
],
"description": "Fixture plugin used by automated tests for plugin support APIs.",
"entrypoint": "./dist/main.js",
"events": [
{
"direction": "serverRelay",
"eventName": "e2e:relay",
"maxPayloadBytes": 2048,
"scope": "server"
},
{
"direction": "p2pHint",
"eventName": "e2e:p2p",
"maxPayloadBytes": 512,
"scope": "user"
}
],
"id": "e2e.plugin-api",
"kind": "client",
"readme": "./README.md",
"schemaVersion": 1,
"title": "E2E Plugin API Fixture",
"version": "1.0.0"
}

BIN
e2e/fixtures/test-tone.wav Normal file

Binary file not shown.

View File

@@ -0,0 +1,42 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
export const TEST_PLUGIN_FIXTURE_DIR = join(__dirname, '..', 'fixtures', 'plugins', 'api-test-plugin');
export const TEST_PLUGIN_ID = 'e2e.plugin-api';
export const TEST_PLUGIN_RELAY_EVENT = 'e2e:relay';
export const TEST_PLUGIN_P2P_EVENT = 'e2e:p2p';
export interface PluginApiTestManifestEvent {
direction: 'clientToServer' | 'serverRelay' | 'p2pHint';
eventName: string;
maxPayloadBytes?: number;
scope: 'server' | 'channel' | 'user' | 'plugin';
}
export interface PluginApiTestManifest {
description: string;
events: PluginApiTestManifestEvent[];
id: string;
title: string;
version: string;
}
export async function readPluginApiTestManifest(): Promise<PluginApiTestManifest> {
const manifestPath = join(TEST_PLUGIN_FIXTURE_DIR, 'toju-plugin.json');
const manifestText = await readFile(manifestPath, 'utf8');
return JSON.parse(manifestText) as PluginApiTestManifest;
}
export function getPluginApiTestEvent(
manifest: PluginApiTestManifest,
eventName: string
): PluginApiTestManifestEvent {
const eventDefinition = manifest.events.find((event) => event.eventName === eventName);
if (!eventDefinition) {
throw new Error(`Expected fixture plugin to define ${eventName}`);
}
return eventDefinition;
}

View File

@@ -0,0 +1,169 @@
import { type BrowserContext, type Page } from '@playwright/test';
const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
const REMOVED_DEFAULT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
export interface SeededEndpointInput {
id: string;
name: string;
url: string;
isActive?: boolean;
isDefault?: boolean;
status?: string;
}
interface SeededEndpointStorageState {
key: string;
removedKey: string;
endpoints: {
id: string;
name: string;
url: string;
isActive: boolean;
isDefault: boolean;
status: string;
}[];
}
function buildSeededEndpointStorageState(
endpointsOrPort: readonly SeededEndpointInput[] | number = Number(process.env.TEST_SERVER_PORT) || 3099
): SeededEndpointStorageState {
const endpoints = Array.isArray(endpointsOrPort)
? endpointsOrPort.map((endpoint) => ({
id: endpoint.id,
name: endpoint.name,
url: endpoint.url,
isActive: endpoint.isActive ?? true,
isDefault: endpoint.isDefault ?? false,
status: endpoint.status ?? 'unknown'
}))
: [
{
id: 'e2e-test-server',
name: 'E2E Test Server',
url: `http://localhost:${endpointsOrPort}`,
isActive: true,
isDefault: false,
status: 'unknown'
}
];
return {
key: SERVER_ENDPOINTS_STORAGE_KEY,
removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY,
endpoints
};
}
function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void {
try {
const storage = window.localStorage;
const currentUserId = storage.getItem('metoyou_currentUserId')?.trim() || null;
const generalSettings = JSON.stringify({
reopenLastViewedChat: false
});
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
storage.setItem(storageState.removedKey, JSON.stringify([
'default',
'toju-primary',
'toju-sweden'
]));
storage.setItem('metoyou_general_settings', generalSettings);
if (currentUserId) {
storage.setItem(`metoyou_general_settings__${encodeURIComponent(currentUserId)}`, generalSettings);
}
const keysToRemove: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key === 'metoyou_lastViewedChat' || key?.startsWith('metoyou_lastViewedChat__')) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
storage.removeItem(key);
}
} catch {
// about:blank and some Playwright UI pages deny localStorage access.
}
}
export async function disableLastViewedChatResume(page: Page): Promise<void> {
await page.evaluate(() => {
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim() || null;
const generalSettings = JSON.stringify({ reopenLastViewedChat: false });
const keysToRemove: string[] = [];
localStorage.setItem('metoyou_general_settings', generalSettings);
if (currentUserId) {
localStorage.setItem(`metoyou_general_settings__${encodeURIComponent(currentUserId)}`, generalSettings);
}
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key === 'metoyou_lastViewedChat' || key?.startsWith('metoyou_lastViewedChat__')) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
localStorage.removeItem(key);
}
});
}
export async function installTestServerEndpoint(
context: BrowserContext,
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
): Promise<void> {
const storageState = buildSeededEndpointStorageState(port);
await context.addInitScript(applySeededEndpointStorageState, storageState);
}
export async function installTestServerEndpoints(
context: BrowserContext,
endpoints: readonly SeededEndpointInput[]
): Promise<void> {
const storageState = buildSeededEndpointStorageState(endpoints);
await context.addInitScript(applySeededEndpointStorageState, storageState);
}
/**
* Seed localStorage with a single signal endpoint pointing at the test server.
* Must be called AFTER navigating to the app origin (localStorage is per-origin)
* but BEFORE the app reads from storage (i.e. before the Angular bootstrap is
* relied upon - calling it in the first goto() landing page is fine since the
* page will re-read on next navigation/reload).
*
* Typical usage:
* await page.goto('/');
* await seedTestServerEndpoint(page);
* await page.reload(); // App now picks up the test endpoint
*/
export async function seedTestServerEndpoint(
page: Page,
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
): Promise<void> {
const storageState = buildSeededEndpointStorageState(port);
await page.evaluate(applySeededEndpointStorageState, storageState);
}
export async function seedTestServerEndpoints(
page: Page,
endpoints: readonly SeededEndpointInput[]
): Promise<void> {
const storageState = buildSeededEndpointStorageState(endpoints);
await page.evaluate(applySeededEndpointStorageState, storageState);
}

View File

@@ -0,0 +1,107 @@
/**
* Launches an isolated MetoYou signaling server for E2E tests.
*
* Creates a temporary data directory so the test server gets its own
* fresh SQLite database. The server process inherits stdio so Playwright
* can watch stdout for readiness and the developer can see logs.
*
* Cleanup: the temp directory is removed when the process exits.
*/
const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs');
const { join } = require('path');
const { tmpdir } = require('os');
const { spawn } = require('child_process');
const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
const SERVER_DIR = join(__dirname, '..', '..', 'server');
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
const TS_NODE_BIN = join(SERVER_DIR, 'node_modules', 'ts-node', 'dist', 'bin.js');
// ── Create isolated temp data directory ──────────────────────────────
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
const dataDir = join(tmpDir, 'data');
mkdirSync(dataDir, { recursive: true });
writeFileSync(
join(dataDir, 'variables.json'),
JSON.stringify({
serverPort: parseInt(TEST_PORT, 10),
serverProtocol: 'http',
serverHost: '',
klipyApiKey: '',
releaseManifestUrl: '',
linkPreview: { enabled: false, cacheTtlMinutes: 60, maxCacheSizeMb: 10 },
})
);
console.log(`[E2E Server] Temp data dir: ${tmpDir}`);
console.log(`[E2E Server] Starting on port ${TEST_PORT}...`);
// ── Spawn the server with cwd = temp dir ─────────────────────────────
// process.cwd() is used by getRuntimeBaseDir() in the server, so data/
// (database, variables.json) will resolve to our temp directory.
// Module resolution (require/import) uses __dirname, so server source
// and node_modules are found from the real server/ directory.
const child = spawn(
process.execPath,
[TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY],
{
cwd: tmpDir,
env: {
...process.env,
PORT: TEST_PORT,
SSL: 'false',
NODE_ENV: 'test',
DB_SYNCHRONIZE: 'true',
},
stdio: 'inherit',
}
);
let shuttingDown = false;
child.on('error', (err) => {
console.error('[E2E Server] Failed to start:', err.message);
cleanup();
process.exit(1);
});
child.on('exit', (code) => {
console.log(`[E2E Server] Exited with code ${code}`);
cleanup();
if (shuttingDown) {
process.exit(0);
}
});
// ── Cleanup on signals ───────────────────────────────────────────────
function cleanup() {
try {
rmSync(tmpDir, { recursive: true, force: true });
console.log(`[E2E Server] Cleaned up temp dir: ${tmpDir}`);
} catch {
// already gone
}
}
function shutdown() {
if (shuttingDown) {
return;
}
shuttingDown = true;
child.kill('SIGTERM');
// Give child 3s to exit, then force kill
setTimeout(() => {
if (child.exitCode === null) {
child.kill('SIGKILL');
}
}, 3_000);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
process.on('exit', cleanup);

132
e2e/helpers/test-server.ts Normal file
View File

@@ -0,0 +1,132 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import { createServer } from 'node:net';
import { join } from 'node:path';
export interface TestServerHandle {
port: number;
url: string;
stop: () => Promise<void>;
}
const E2E_DIR = join(__dirname, '..');
const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
export 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 await 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);
});
}

View File

@@ -0,0 +1,916 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type Page } from '@playwright/test';
/**
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
* Tracks all created peer connections and their remote tracks so tests
* can inspect WebRTC state via `page.evaluate()`.
*
* Call immediately after page creation, before any `goto()`.
*/
export async function installWebRTCTracking(page: Page): Promise<void> {
await page.addInitScript(() => {
const connections: RTCPeerConnection[] = [];
const syntheticMediaResources: {
audioCtx: AudioContext;
source?: AudioScheduledSourceNode;
drawIntervalId?: number;
}[] = [];
(window as any).__rtcConnections = connections;
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
const OriginalRTCPeerConnection = window.RTCPeerConnection;
(window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) {
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
connections.push(pc);
pc.addEventListener('connectionstatechange', () => {
(window as any).__lastRtcState = pc.connectionState;
});
pc.addEventListener('track', (event: RTCTrackEvent) => {
(window as any).__rtcRemoteTracks.push({
kind: event.track.kind,
id: event.track.id,
readyState: event.track.readyState
});
});
return pc;
} as any;
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
// 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();
if (audioCtx.state === 'suspended') {
try {
await audioCtx.resume();
} catch {}
}
const audioTrack = dest.stream.getAudioTracks()[0];
// Combine video + audio into one stream
const resultStream = new MediaStream([videoTrack, audioTrack]);
syntheticMediaResources.push({
audioCtx,
source: osc,
drawIntervalId: drawInterval as unknown as number
});
audioTrack.addEventListener('ended', () => {
clearInterval(drawInterval);
try {
osc.stop();
} catch {}
void audioCtx.close().catch(() => {});
}, { once: true });
// Tag the stream so tests can identify it
(resultStream as any).__isScreenShare = true;
return resultStream;
};
});
}
/**
* Wait until at least one RTCPeerConnection reaches the 'connected' state.
*/
/**
* Ensure every `AudioContext` created by the page auto-resumes so that
* the input-gain Web Audio pipeline (`source -> gain -> destination`) never
* stalls in the "suspended" state.
*
* On Linux with multiple headless Chromium instances, `new AudioContext()`
* can start suspended without a user-gesture gate, causing the media
* pipeline to emit only a single RTP packet.
*
* Call once per page, BEFORE navigating, alongside `installWebRTCTracking`.
*/
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
await page.addInitScript(() => {
const OrigAudioContext = window.AudioContext;
(window as any).AudioContext = function(this: AudioContext, ...args: any[]) {
const ctx: AudioContext = new OrigAudioContext(...args);
// Track all created AudioContexts for test diagnostics
const tracked = ((window as any).__trackedAudioContexts ??= []) as AudioContext[];
tracked.push(ctx);
if (ctx.state === 'suspended') {
ctx.resume().catch(() => { /* noop */ });
}
// Also catch transitions to suspended after creation
ctx.addEventListener('statechange', () => {
if (ctx.state === 'suspended') {
ctx.resume().catch(() => { /* noop */ });
}
});
return ctx;
} as any;
(window as any).AudioContext.prototype = OrigAudioContext.prototype;
Object.setPrototypeOf((window as any).AudioContext, OrigAudioContext);
});
}
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
await page.waitForFunction(
() => (window as any).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false,
{ timeout }
);
}
/**
* Check that a peer connection is still in 'connected' state (not failed/disconnected).
*/
export async function isPeerStillConnected(page: Page): Promise<boolean> {
return page.evaluate(
() => (window as any).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false
);
}
/** Returns the number of tracked peer connections in `connected` state. */
export async function getConnectedPeerCount(page: Page): Promise<number> {
return page.evaluate(
() => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
(pc) => pc.connectionState === 'connected'
).length ?? 0
);
}
/** Wait until the expected number of peer connections are `connected`. */
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
await page.waitForFunction(
(count) => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
(pc) => pc.connectionState === 'connected'
).length === count,
expectedCount,
{ timeout }
);
}
/**
* Resume all suspended AudioContext instances created by the synthetic
* media patch. Uses CDP `Runtime.evaluate` with `userGesture: true` so
* Chrome treats the call as a user-gesture - this satisfies the autoplay
* policy that otherwise blocks `AudioContext.resume()`.
*/
export async function resumeSyntheticAudioContexts(page: Page): Promise<number> {
const cdpSession = await page.context().newCDPSession(page);
try {
const result = await cdpSession.send('Runtime.evaluate', {
expression: `(async () => {
const resources = window.__rtcSyntheticMediaResources;
if (!resources) return 0;
let resumed = 0;
for (const r of resources) {
if (r.audioCtx.state === 'suspended') {
await r.audioCtx.resume();
resumed++;
}
}
return resumed;
})()`,
awaitPromise: true,
userGesture: true
});
return result.result.value ?? 0;
} finally {
await cdpSession.detach();
}
}
interface PerPeerAudioStat {
connectionState: string;
inboundBytes: number;
inboundPackets: number;
outboundBytes: number;
outboundPackets: number;
}
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) {
return [];
}
const snapshots: PerPeerAudioStat[] = [];
for (const pc of connections) {
let inboundBytes = 0;
let inboundPackets = 0;
let outboundBytes = 0;
let outboundPackets = 0;
try {
const stats = await pc.getStats();
stats.forEach((report: any) => {
const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'audio') {
outboundBytes += report.bytesSent ?? 0;
outboundPackets += report.packetsSent ?? 0;
}
if (report.type === 'inbound-rtp' && kind === 'audio') {
inboundBytes += report.bytesReceived ?? 0;
inboundPackets += report.packetsReceived ?? 0;
}
});
} catch {
// Closed connection.
}
snapshots.push({
connectionState: pc.connectionState,
inboundBytes,
inboundPackets,
outboundBytes,
outboundPackets
});
}
return snapshots;
});
}
/** Wait until every connected peer connection shows inbound and outbound audio flow. */
export async function waitForAllPeerAudioFlow(
page: Page,
expectedConnectedPeers: number,
timeoutMs = 45_000,
pollIntervalMs = 1_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
// Track which peer indices have been confirmed flowing at least once.
// This prevents a peer from being missed just because it briefly paused
// during one specific poll interval.
const confirmedFlowing = new Set<number>();
let previous = await getPerPeerAudioStats(page);
while (Date.now() < deadline) {
await page.waitForTimeout(pollIntervalMs);
const current = await getPerPeerAudioStats(page);
const connectedPeers = current.filter((stat) => stat.connectionState === 'connected');
if (connectedPeers.length >= expectedConnectedPeers) {
for (let index = 0; index < current.length; index++) {
const curr = current[index];
if (!curr || curr.connectionState !== 'connected') {
continue;
}
const prev = previous[index] ?? {
connectionState: 'new',
inboundBytes: 0,
inboundPackets: 0,
outboundBytes: 0,
outboundPackets: 0
};
const inboundFlowing = curr.inboundBytes > prev.inboundBytes || curr.inboundPackets > prev.inboundPackets;
const outboundFlowing = curr.outboundBytes > prev.outboundBytes || curr.outboundPackets > prev.outboundPackets;
if (inboundFlowing && outboundFlowing) {
confirmedFlowing.add(index);
}
}
// Check if enough peers have been confirmed across all samples
const connectedIndices = current
.map((stat, idx) => stat.connectionState === 'connected' ? idx : -1)
.filter((idx) => idx >= 0);
const confirmedCount = connectedIndices.filter((idx) => confirmedFlowing.has(idx)).length;
if (confirmedCount >= expectedConnectedPeers) {
return;
}
}
previous = current;
}
throw new Error(`Timed out waiting for ${expectedConnectedPeers} peers with bidirectional audio flow`);
}
/**
* Get outbound and inbound audio RTP stats aggregated across all peer
* 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<{
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 HWMEntry {
outBytesSent: number;
outPacketsSent: number;
inBytesReceived: number;
inPacketsReceived: number;
hasOutbound: boolean;
hasInbound: boolean;
};
const hwm: Record<number, HWMEntry> = (window as any).__rtcStatsHWM =
((window as any).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
for (let idx = 0; idx < connections.length; idx++) {
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) => {
const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'audio') {
hasOut = true;
obytes += report.bytesSent ?? 0;
opackets += report.packetsSent ?? 0;
}
if (report.type === 'inbound-rtp' && kind === 'audio') {
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
};
});
}
/**
* Snapshot audio stats, wait `durationMs`, snapshot again, and return the delta.
* Useful for verifying audio is actively flowing (bytes increasing).
*/
export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promise<{
outboundBytesDelta: number;
inboundBytesDelta: number;
outboundPacketsDelta: number;
inboundPacketsDelta: number;
}> {
const before = await getAudioStats(page);
await page.waitForTimeout(durationMs);
const after = await getAudioStats(page);
return {
outboundBytesDelta: (after.outbound?.bytesSent ?? 0) - (before.outbound?.bytesSent ?? 0),
inboundBytesDelta: (after.inbound?.bytesReceived ?? 0) - (before.inbound?.bytesReceived ?? 0),
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');
});
}

View File

@@ -0,0 +1,144 @@
import {
expect,
type Locator,
type Page
} from '@playwright/test';
export interface 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();
}
}

444
e2e/pages/chat-room.page.ts Normal file
View File

@@ -0,0 +1,444 @@
import {
expect,
type Page,
type Locator
} from '@playwright/test';
export class ChatRoomPage {
readonly chatMessages: Locator;
readonly voiceWorkspace: Locator;
readonly channelsSidePanel: Locator;
readonly usersSidePanel: Locator;
constructor(private page: Page) {
this.chatMessages = page.locator('app-chat-messages');
this.voiceWorkspace = page.locator('app-voice-workspace');
this.channelsSidePanel = page.locator('app-rooms-side-panel').first();
this.usersSidePanel = page.locator('app-rooms-side-panel').last();
}
/** Click a voice channel by name in the channels sidebar to join voice. */
async joinVoiceChannel(channelName: string) {
const channelButton = this.getVoiceChannelButton(channelName);
if (await channelButton.count() === 0) {
await this.refreshRoomMetadata();
}
if (await channelButton.count() === 0) {
// Second attempt - metadata might still be syncing
await this.page.waitForTimeout(2_000);
await this.refreshRoomMetadata();
}
await expect(channelButton).toBeVisible({ timeout: 15_000 });
await channelButton.click();
}
/** Creates a voice channel if it is not already present in the current room. */
async ensureVoiceChannelExists(channelName: string) {
const channelButton = this.getVoiceChannelButton(channelName);
if (await channelButton.count() > 0) {
return;
}
await this.refreshRoomMetadata();
// Wait a bit longer for Angular to render the channel list after refresh
try {
await expect(channelButton).toBeVisible({ timeout: 5_000 });
return;
} catch {
// Channel genuinely doesn't exist - create it
}
await this.openCreateVoiceChannelDialog();
try {
await this.createChannel(channelName);
} catch {
// If the dialog didn't close (e.g. duplicate name validation), dismiss it
const dialog = this.page.locator('app-confirm-dialog');
if (await dialog.isVisible()) {
const cancelButton = dialog.getByRole('button', { name: 'Cancel' });
const closeButton = dialog.getByRole('button', { name: 'Close dialog' });
if (await cancelButton.isVisible()) {
await cancelButton.click();
} else if (await closeButton.isVisible()) {
await closeButton.click();
}
await expect(dialog).not.toBeVisible({ timeout: 5_000 }).catch(() => {});
}
}
await expect(channelButton).toBeVisible({ timeout: 15_000 });
}
/** 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. */
async openCreateVoiceChannelDialog() {
await this.page.locator('button[title="Create Voice Channel"]').click();
}
/** Click "Create Text Channel" button in the channels sidebar. */
async openCreateTextChannelDialog() {
await this.page.locator('button[title="Create Text Channel"]').click();
}
/** Fill the channel name in the create channel dialog and confirm. */
async createChannel(name: string) {
const dialog = this.page.locator('app-confirm-dialog');
const channelNameInput = dialog.getByPlaceholder('Channel name');
const createButton = dialog.getByRole('button', { name: 'Create', exact: true });
await expect(channelNameInput).toBeVisible({ timeout: 10_000 });
await channelNameInput.fill(name);
await channelNameInput.press('Enter');
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 voiceControls() {
return this.page.locator('app-voice-controls');
}
/** Get the mute toggle button inside voice controls. */
get muteButton() {
return this.voiceControls.locator('button:has(ng-icon[name="lucideMic"]), button:has(ng-icon[name="lucideMicOff"])').first();
}
/** Get the deafen toggle button inside voice controls. */
get deafenButton() {
return this.voiceControls.locator('button:has(ng-icon[name="lucideHeadphones"])').first();
}
/** Get the disconnect/hang-up button (destructive styled). */
get disconnectButton() {
return this.voiceControls.locator('button:has(ng-icon[name="lucidePhoneOff"])').first();
}
/** Get all voice stream tiles. */
get streamTiles() {
return this.page.locator('app-voice-workspace-stream-tile');
}
/** Get the count of voice users listed under a voice channel. */
async getVoiceUserCountInChannel(channelName: string): Promise<number> {
// The voice channel button is inside a wrapper div; user avatars are siblings within that wrapper
const channelWrapper = this.getVoiceChannelButton(channelName).locator('xpath=ancestor::div[1]');
const userAvatars = channelWrapper.locator('app-user-avatar');
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 {
return this.channelsSidePanel.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`).first();
}
private getVoiceChannelButton(channelName: string): Locator {
return this.channelsSidePanel.locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`).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);
}
}

33
e2e/pages/login.page.ts Normal file
View File

@@ -0,0 +1,33 @@
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly form: Locator;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly serverSelect: Locator;
readonly submitButton: Locator;
readonly errorText: Locator;
readonly registerLink: Locator;
constructor(private page: Page) {
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]')
.first();
this.usernameInput = page.locator('#login-username');
this.passwordInput = page.locator('#login-password');
this.serverSelect = page.locator('#login-server');
this.submitButton = this.form.getByRole('button', { name: 'Login' });
this.errorText = page.locator('.text-destructive');
this.registerLink = this.form.getByRole('button', { name: 'Register' });
}
async goto() {
await this.page.goto('/login');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}

View File

@@ -0,0 +1,53 @@
import {
expect,
type Page,
type Locator
} from '@playwright/test';
export class RegisterPage {
readonly usernameInput: Locator;
readonly displayNameInput: Locator;
readonly passwordInput: Locator;
readonly serverSelect: Locator;
readonly submitButton: Locator;
readonly errorText: Locator;
readonly loginLink: Locator;
constructor(private page: Page) {
this.usernameInput = page.locator('#register-username');
this.displayNameInput = page.locator('#register-display-name');
this.passwordInput = page.locator('#register-password');
this.serverSelect = page.locator('#register-server');
this.submitButton = page.getByRole('button', { name: 'Create Account' });
this.errorText = page.locator('.text-destructive');
this.loginLink = page.getByRole('button', { name: 'Login' });
}
async goto() {
await this.page.goto('/register', { waitUntil: 'domcontentloaded' });
try {
await expect(this.usernameInput).toBeVisible({ timeout: 10_000 });
} catch {
// Angular router may redirect to /login on first load; use the
// visible login-form action instead of broad text matching.
const registerButton = this.page.getByRole('button', { name: 'Register', exact: true }).last();
await expect(registerButton).toBeVisible({ timeout: 10_000 });
await registerButton.click();
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
}
await expect(this.submitButton).toBeVisible({ timeout: 30_000 });
}
async register(username: string, displayName: string, password: string) {
await this.usernameInput.fill(username);
await expect(this.usernameInput).toHaveValue(username);
await this.displayNameInput.fill(displayName);
await expect(this.displayNameInput).toHaveValue(displayName);
await this.passwordInput.fill(password);
await expect(this.passwordInput).toHaveValue(password);
await this.submitButton.click();
}
}

View File

@@ -0,0 +1,97 @@
import {
type Page,
type Locator,
expect
} from '@playwright/test';
export class ServerSearchPage {
readonly searchInput: Locator;
readonly createServerButton: Locator;
readonly railCreateServerButton: Locator;
readonly searchCreateServerButton: Locator;
readonly settingsButton: Locator;
// Create server dialog
readonly serverNameInput: Locator;
readonly serverDescriptionInput: Locator;
readonly serverTopicInput: Locator;
readonly signalEndpointSelect: Locator;
readonly privateCheckbox: Locator;
readonly serverPasswordInput: Locator;
readonly dialogCreateButton: Locator;
readonly dialogCancelButton: Locator;
constructor(private page: Page) {
this.searchInput = page.getByPlaceholder('Search servers and users...');
this.railCreateServerButton = page.locator('button[title="Create Server"]');
this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' });
this.createServerButton = this.searchCreateServerButton;
this.settingsButton = page.locator('button[title="Settings"]');
// Create dialog elements
this.serverNameInput = page.locator('#create-server-name');
this.serverDescriptionInput = page.locator('#create-server-description');
this.serverTopicInput = page.locator('#create-server-topic');
this.signalEndpointSelect = page.locator('#create-server-signal-endpoint');
this.privateCheckbox = page.locator('#private');
this.serverPasswordInput = page.locator('#create-server-password');
this.dialogCreateButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Create' });
this.dialogCancelButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Cancel' });
}
async goto() {
await this.page.goto('/search');
}
async createServer(name: string, options?: { description?: string; topic?: string; sourceId?: string }) {
if (!await this.serverNameInput.isVisible()) {
if (await this.searchCreateServerButton.isVisible()) {
await this.searchCreateServerButton.click();
} else {
await this.railCreateServerButton.click();
if (!await this.serverNameInput.isVisible()) {
await expect(this.searchCreateServerButton).toBeVisible({ timeout: 10_000 });
await this.searchCreateServerButton.click();
}
}
}
await expect(this.serverNameInput).toBeVisible();
await this.serverNameInput.fill(name);
if (options?.description) {
await this.serverDescriptionInput.fill(options.description);
}
if (options?.topic) {
await this.serverTopicInput.fill(options.topic);
}
if (options?.sourceId) {
await this.signalEndpointSelect.selectOption(options.sourceId);
}
await this.dialogCreateButton.click();
}
async joinSavedRoom(name: string) {
await this.page.getByRole('button', { name }).click();
}
async joinServerFromSearch(name: string, options: { acceptPluginDownloads?: boolean } = {}) {
await this.searchInput.fill(name);
const serverCard = this.page.locator('div[title]', { hasText: name }).first();
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.dblclick();
if (options.acceptPluginDownloads) {
const pluginConsentDialog = this.page.getByRole('dialog', { name: /uses plugins/ });
await expect(pluginConsentDialog).toBeVisible({ timeout: 20_000 });
await pluginConsentDialog.getByRole('button', { name: 'Accept and join' }).click();
}
}
}

40
e2e/playwright.config.ts Normal file
View File

@@ -0,0 +1,40 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 90_000,
expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [['html', { outputFolder: '../test-results/html-report', open: 'never' }], ['list']],
outputDir: '../test-results/artifacts',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
actionTimeout: 15_000
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
permissions: ['microphone', 'camera'],
launchOptions: {
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
'--autoplay-policy=no-user-gesture-required'
]
}
}
}
],
webServer: {
command: 'cd ../toju-app && npx ng serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 120_000
}
});

View File

@@ -0,0 +1,284 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
chromium,
type BrowserContext,
type Page
} from '@playwright/test';
import { test, expect } from '../../fixtures/multi-client';
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
interface TestUser {
username: string;
displayName: string;
password: string;
}
interface PersistentClient {
context: BrowserContext;
page: Page;
userDataDir: string;
}
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
test.describe('User session data isolation', () => {
test.describe.configure({ timeout: 240_000 });
test('preserves a user saved rooms and local history across app restarts', async ({ testServer }) => {
const suffix = uniqueName('persist');
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-persist-'));
const alice: TestUser = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const aliceServerName = `Alice Session Server ${suffix}`;
const aliceMessage = `Alice persisted message ${suffix}`;
let client: PersistentClient | null = null;
try {
client = await launchPersistentClient(userDataDir, testServer.port);
await test.step('Alice registers and creates local chat history', async () => {
await registerUser(client.page, alice);
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
});
await test.step('Alice sees the same saved room and message after a full restart', async () => {
await restartPersistentClient(client, testServer.port);
await openApp(client.page);
await expect(client.page).not.toHaveURL(/\/login/, { timeout: 15_000 });
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
});
} finally {
await closePersistentClient(client);
await rm(userDataDir, { recursive: true, force: true });
}
});
test('gives a new user a blank slate and restores only that user local data after account switches', async ({ testServer }) => {
const suffix = uniqueName('isolation');
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-isolation-'));
const alice: TestUser = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const bob: TestUser = {
username: `bob_${suffix}`,
displayName: 'Bob',
password: 'TestPass123!'
};
const aliceServerName = `Alice Private Server ${suffix}`;
const bobServerName = `Bob Private Server ${suffix}`;
const aliceMessage = `Alice history ${suffix}`;
const bobMessage = `Bob history ${suffix}`;
let client: PersistentClient | null = null;
try {
client = await launchPersistentClient(userDataDir, testServer.port);
await test.step('Alice creates persisted local data and verifies it survives a restart', async () => {
await registerUser(client.page, alice);
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
await restartPersistentClient(client, testServer.port);
await openApp(client.page);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
});
await test.step('Bob starts from a blank slate in the same browser profile', async () => {
await logoutUser(client.page);
await registerUser(client.page, bob);
await expectBlankSlate(client.page, [aliceServerName]);
});
await test.step('Bob gets only his own saved room and history after a restart', async () => {
await createServerAndSendMessage(client.page, bobServerName, bobMessage);
await restartPersistentClient(client, testServer.port);
await openApp(client.page);
await expectSavedRoomAndHistory(client.page, bobServerName, bobMessage);
await expectSavedRoomHidden(client.page, aliceServerName);
});
await test.step('When Alice logs back in she sees only Alice local data, not Bob data', async () => {
await logoutUser(client.page);
await restartPersistentClient(client, testServer.port);
await loginUser(client.page, alice);
await expectSavedRoomVisible(client.page, aliceServerName);
await expectSavedRoomHidden(client.page, bobServerName);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
});
} finally {
await closePersistentClient(client);
await rm(userDataDir, { recursive: true, force: true });
}
});
});
async function launchPersistentClient(userDataDir: string, testServerPort: number): Promise<PersistentClient> {
const context = await chromium.launchPersistentContext(userDataDir, {
args: CLIENT_LAUNCH_ARGS,
baseURL: 'http://localhost:4200',
permissions: ['microphone', 'camera']
});
await installTestServerEndpoint(context, testServerPort);
const page = context.pages()[0] ?? (await context.newPage());
return {
context,
page,
userDataDir
};
}
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
await client.context.close();
const restartedClient = await launchPersistentClient(client.userDataDir, testServerPort);
client.context = restartedClient.context;
client.page = restartedClient.page;
}
async function closePersistentClient(client: PersistentClient | null): Promise<void> {
if (!client) {
return;
}
await client.context.close().catch(() => {});
}
async function openApp(page: Page): Promise<void> {
await retryTransientNavigation(() => page.goto('/', { waitUntil: 'domcontentloaded' }));
}
async function registerUser(page: Page, user: TestUser): Promise<void> {
const registerPage = new RegisterPage(page);
await retryTransientNavigation(() => registerPage.goto());
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
}
async function loginUser(page: Page, user: TestUser): Promise<void> {
const loginPage = new LoginPage(page);
await retryTransientNavigation(() => loginPage.goto());
await loginPage.login(user.username, user.password);
await expect(page).toHaveURL(/\/(search|room)(\/|$)/, { timeout: 15_000 });
}
async function logoutUser(page: Page): Promise<void> {
const menuButton = page.getByRole('button', { name: 'Menu' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
const loginPage = new LoginPage(page);
await expect(menuButton).toBeVisible({ timeout: 10_000 });
await menuButton.click();
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
await logoutButton.click();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
}
async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise<void> {
const searchPage = new ServerSearchPage(page);
const messagesPage = new ChatMessagesPage(page);
await searchPage.createServer(serverName, {
description: `User session isolation coverage for ${serverName}`
});
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
await messagesPage.sendMessage(messageText);
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
}
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> {
const railRoomButton = getRailSavedRoomButton(page, roomName);
const messagesPage = new ChatMessagesPage(page);
await expect(railRoomButton).toBeVisible({ timeout: 20_000 });
await page.goto('/search', { waitUntil: 'domcontentloaded' });
const searchRoomButton = getSearchSavedRoomButton(page, roomName);
await expect(searchRoomButton).toBeVisible({ timeout: 20_000 });
await searchRoomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
}
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
const searchPage = new ServerSearchPage(page);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(searchPage.createServerButton).toBeVisible({ timeout: 15_000 });
for (const roomName of hiddenRoomNames) {
await expectSavedRoomHidden(page, roomName);
}
}
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
await expect(getRailSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
await page.goto('/search', { waitUntil: 'domcontentloaded' });
await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
}
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
await expect(getRailSavedRoomButton(page, roomName)).toHaveCount(0);
if (!page.url().includes('/search')) {
await page.goto('/search', { waitUntil: 'domcontentloaded' });
}
await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0);
}
function getRailSavedRoomButton(page: Page, roomName: string) {
return page.locator(`button[title="${roomName}"]`).first();
}
function getSearchSavedRoomButton(page: Page, roomName: string) {
return page.locator('app-server-search').getByRole('button', { name: roomName, exact: true });
}
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await navigate();
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');
if (!isTransientNavigationError || attempt === attempts) {
throw error;
}
}
}
throw lastError instanceof Error ? lastError : new Error(`Navigation failed after ${attempts} attempts`);
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -0,0 +1,460 @@
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('shows per-server channel lists on first saved-server click', async ({ createClient }) => {
const scenario = await createSingleClientChatScenario(createClient);
const alphaServerName = `Alpha Server ${uniqueName('rail')}`;
const betaServerName = `Beta Server ${uniqueName('rail')}`;
const alphaChannelName = uniqueName('alpha-updates');
const betaChannelName = uniqueName('beta-plans');
const channelsPanel = scenario.room.channelsSidePanel;
await test.step('Create first saved server with a unique text channel', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail switch alpha server');
await scenario.room.ensureTextChannelExists(alphaChannelName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
});
await test.step('Create second saved server with a different text channel', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail switch beta server');
await scenario.room.ensureTextChannelExists(betaChannelName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
});
await test.step('Opening first server once restores only its channels', async () => {
await openSavedRoomByName(scenario.client.page, alphaServerName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toHaveCount(0);
});
await test.step('Opening second server once restores only its channels', async () => {
await openSavedRoomByName(scenario.client.page, betaServerName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toHaveCount(0);
});
});
test('shows local room history on first saved-server click', async ({ createClient }) => {
const scenario = await createSingleClientChatScenario(createClient);
const alphaServerName = `History Alpha ${uniqueName('rail')}`;
const betaServerName = `History Beta ${uniqueName('rail')}`;
const alphaMessage = `Alpha history message ${uniqueName('msg')}`;
const betaMessage = `Beta history message ${uniqueName('msg')}`;
await test.step('Create first server and send a local message', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail history alpha server');
await scenario.messages.sendMessage(alphaMessage);
await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Create second server and send a different local message', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail history beta server');
await scenario.messages.sendMessage(betaMessage);
await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Opening first server once restores its history immediately', async () => {
await openSavedRoomByName(scenario.client.page, alphaServerName);
await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Opening second server once restores its history immediately', async () => {
await openSavedRoomByName(scenario.client.page, betaServerName);
await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_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');
});
});
});
interface ChatScenario {
alice: Client;
bob: Client;
aliceRoom: ChatRoomPage;
bobRoom: ChatRoomPage;
aliceMessages: ChatMessagesPage;
bobMessages: ChatMessagesPage;
}
interface SingleClientChatScenario {
client: Client;
messages: ChatMessagesPage;
room: ChatRoomPage;
search: ServerSearchPage;
}
async function createSingleClientChatScenario(createClient: () => Promise<Client>): Promise<SingleClientChatScenario> {
const suffix = uniqueName('solo');
const client = await createClient();
const credentials = {
username: `solo_${suffix}`,
displayName: 'Solo',
password: 'TestPass123!'
};
await installChatFeatureMocks(client.page);
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(
credentials.username,
credentials.displayName,
credentials.password
);
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
return {
client,
messages: new ChatMessagesPage(client.page),
room: new ChatRoomPage(client.page),
search: new ServerSearchPage(client.page)
};
}
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);
await bobSearchPage.joinServerFromSearch(serverName);
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 createServerAndOpenRoom(
searchPage: ServerSearchPage,
page: Page,
serverName: string,
description: string
): Promise<void> {
await searchPage.createServer(serverName, { description });
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
await waitForCurrentRoomName(page, serverName);
}
async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
const roomButton = page.locator(`button[title="${roomName}"]`);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(expectedRoomName) => {
interface RoomShape { name?: string }
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 false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
return currentRoom?.name === expectedRoomName;
},
roomName,
{ timeout }
);
}
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)}`;
}

View File

@@ -0,0 +1,137 @@
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 { ChatMessagesPage } from '../../pages/chat-messages.page';
import { disableLastViewedChatResume } from '../../helpers/seed-test-endpoint';
test.describe('Direct message flow', () => {
test.describe.configure({ timeout: 180_000 });
test('opens a DM from a user card and queues messages while offline', async ({ createClient }) => {
const scenario = await createDmScenario(createClient);
const offlineMessage = `Offline DM ${uniqueName('msg')}`;
await test.step('Alice opens Bob from the room user list', async () => {
const bobUserCard = scenario.alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
await expect(bobUserCard).toBeVisible({ timeout: 20_000 });
await bobUserCard.getByRole('button', { name: 'Message Bob' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
await expect(scenario.alice.page.getByRole('heading', { name: 'Bob' })).toBeVisible({ timeout: 10_000 });
});
await test.step('Offline send persists locally as queued', async () => {
await scenario.alice.page.evaluate(() => window.simulateOffline?.());
await scenario.alice.page.getByTestId('dm-input').fill(offlineMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.alice.page.locator('app-dm-chat').getByText(offlineMessage)).toBeVisible({ timeout: 10_000 });
await expect(scenario.alice.page.getByTestId('message-status').last()).toContainText('QUEUED');
});
});
test('delivers a live DM to the recipient conversation', async ({ createClient }) => {
const scenario = await createDmScenario(createClient);
const liveMessage = `Live DM ${uniqueName('msg')}`;
await openDmFromRoomUserCard(scenario.alice.page, 'Bob');
await scenario.alice.page.getByTestId('dm-input').fill(liveMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await openDmFromRoomUserCard(scenario.bob.page, 'Alice');
await expect(scenario.bob.page.locator('app-dm-chat').getByText(liveMessage)).toBeVisible({ timeout: 20_000 });
});
test('shows friend and message actions on the search people list', async ({ createClient }) => {
const scenario = await createDmScenario(createClient);
await disableLastViewedChatResume(scenario.alice.page);
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 });
await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
const bobPeopleCard = scenario.alice.page
.locator('app-user-search-list [data-testid$="-' + scenario.bobUserId + '"]', { hasText: 'Bob' })
.first();
await expect(bobPeopleCard).toBeVisible({ timeout: 15_000 });
const friendButton = bobPeopleCard.locator(`[data-testid="friend-button-${scenario.bobUserId}"]`);
const messageButton = bobPeopleCard.getByRole('button', { name: 'Message Bob' });
await expect(friendButton).toBeAttached({ timeout: 15_000 });
await expect(messageButton).toBeAttached({ timeout: 15_000 });
});
});
interface DmScenario {
alice: Client;
bob: Client;
bobUserId: string;
aliceSearch: ServerSearchPage;
}
async function createDmScenario(createClient: () => Promise<Client>): Promise<DmScenario> {
const suffix = uniqueName('dm');
const serverName = `DM Server ${suffix}`;
const alice = await createClient();
const bob = await createClient();
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, { description: 'E2E direct message discovery server' });
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await new ChatMessagesPage(alice.page).waitForReady();
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await new ChatMessagesPage(bob.page).waitForReady();
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
await expect(bobRoomCard).toBeVisible({ timeout: 20_000 });
const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid');
const bobUserId = bobUserCardTestId?.replace('room-user-card-', '');
if (!bobUserId) {
throw new Error('Expected Bob room user card to expose a stable test id.');
}
return {
alice,
bob,
bobUserId,
aliceSearch
};
}
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, 'TestPass123!');
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
}
async function openDmFromRoomUserCard(page: Page, displayName: string): Promise<void> {
const userCard = page.locator('[data-testid^="room-user-card-"]', { hasText: displayName }).first();
await expect(userCard).toBeVisible({ timeout: 20_000 });
await userCard.getByRole('button', { name: `Message ${displayName}` }).click();
await expect(page).toHaveURL(/\/dm\//, { timeout: 15_000 });
await expect(page.getByRole('heading', { name: displayName })).toBeVisible({ timeout: 10_000 });
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -0,0 +1,260 @@
import {
expect,
type Locator,
type Page
} from '@playwright/test';
import { test, 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 } from '../../pages/chat-messages.page';
interface DesktopNotificationRecord {
title: string;
body: string;
}
interface NotificationScenario {
alice: Client;
bob: Client;
aliceRoom: ChatRoomPage;
bobRoom: ChatRoomPage;
bobMessages: ChatMessagesPage;
serverName: string;
channelName: string;
}
test.describe('Chat notifications', () => {
test.describe.configure({ timeout: 180_000 });
test('shows desktop notifications and unread badges for inactive channels', async ({ createClient }) => {
const scenario = await createNotificationScenario(createClient);
const message = `Background notification ${uniqueName('msg')}`;
await test.step('Bob sends a message to Alice\'s inactive channel', async () => {
await clearDesktopNotifications(scenario.alice.page);
await scenario.bobRoom.joinTextChannel(scenario.channelName);
await scenario.bobMessages.sendMessage(message);
});
await test.step('Alice receives a desktop notification with the channel preview', async () => {
const notification = await waitForDesktopNotification(scenario.alice.page);
expect(notification).toEqual({
title: `${scenario.serverName} · #${scenario.channelName}`,
body: `Bob: ${message}`
});
});
await test.step('Alice sees unread badges for the room and the inactive channel', async () => {
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 });
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
});
});
test('keeps unread badges visible when a muted channel suppresses desktop popups', async ({ createClient }) => {
const scenario = await createNotificationScenario(createClient);
const message = `Muted notification ${uniqueName('msg')}`;
await test.step('Alice mutes the inactive text channel', async () => {
await muteTextChannel(scenario.alice.page, scenario.channelName);
await clearDesktopNotifications(scenario.alice.page);
});
await test.step('Bob sends a message into the muted channel', async () => {
await scenario.bobRoom.joinTextChannel(scenario.channelName);
await scenario.bobMessages.sendMessage(message);
});
await test.step('Alice still sees unread badges for the room and channel', async () => {
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 });
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
});
await test.step('Alice does not get a muted desktop popup', async () => {
const notificationAppeared = await waitForAnyDesktopNotification(scenario.alice.page, 1_500);
expect(notificationAppeared).toBe(false);
});
});
});
async function createNotificationScenario(createClient: () => Promise<Client>): Promise<NotificationScenario> {
const suffix = uniqueName('notify');
const serverName = `Notifications Server ${suffix}`;
const channelName = uniqueName('updates');
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 installDesktopNotificationSpy(alice.page);
await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password);
await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password);
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, {
description: 'E2E notification coverage server'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName);
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();
await aliceRoom.ensureTextChannelExists(channelName);
await expect(getTextChannelButton(alice.page, channelName)).toBeVisible({ timeout: 20_000 });
return {
alice,
bob,
aliceRoom,
bobRoom,
bobMessages,
serverName,
channelName
};
}
async function registerUser(page: Page, username: string, displayName: string, password: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, password);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
}
async function installDesktopNotificationSpy(page: Page): Promise<void> {
await page.addInitScript(() => {
const notifications: DesktopNotificationRecord[] = [];
class MockNotification {
static permission = 'granted';
onclick: (() => void) | null = null;
constructor(title: string, options?: NotificationOptions) {
notifications.push({
title,
body: options?.body ?? ''
});
}
static async requestPermission(): Promise<NotificationPermission> {
return 'granted';
}
close(): void {
return;
}
}
Object.defineProperty(window, '__desktopNotifications', {
value: notifications,
configurable: true
});
Object.defineProperty(window, 'Notification', {
value: MockNotification,
configurable: true,
writable: true
});
});
}
async function clearDesktopNotifications(page: Page): Promise<void> {
await page.evaluate(() => {
(window as WindowWithDesktopNotifications).__desktopNotifications.length = 0;
});
}
async function waitForDesktopNotification(page: Page): Promise<DesktopNotificationRecord> {
await expect.poll(
async () => (await readDesktopNotifications(page)).length,
{
timeout: 20_000,
message: 'Expected a desktop notification to be emitted'
}
).toBeGreaterThan(0);
const notifications = await readDesktopNotifications(page);
return notifications[notifications.length - 1];
}
async function waitForAnyDesktopNotification(page: Page, timeout: number): Promise<boolean> {
try {
await page.waitForFunction(
() => (window as WindowWithDesktopNotifications).__desktopNotifications.length > 0,
undefined,
{ timeout }
);
return true;
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
return false;
}
throw error;
}
}
async function readDesktopNotifications(page: Page): Promise<DesktopNotificationRecord[]> {
return page.evaluate(() => {
return [...(window as WindowWithDesktopNotifications).__desktopNotifications];
});
}
async function muteTextChannel(page: Page, channelName: string): Promise<void> {
const channelButton = getTextChannelButton(page, channelName);
const contextMenu = page.locator('app-context-menu');
await expect(channelButton).toBeVisible({ timeout: 20_000 });
await channelButton.click({ button: 'right' });
await expect(contextMenu.getByRole('button', { name: 'Mute Notifications' })).toBeVisible({ timeout: 10_000 });
await contextMenu.getByRole('button', { name: 'Mute Notifications' }).click();
await expect(contextMenu).toHaveCount(0);
}
function getSavedRoomButton(page: Page, roomName: string): Locator {
return page.locator(`button[title="${roomName}"]`).first();
}
function getTextChannelButton(page: Page, channelName: string): Locator {
return page.locator('app-rooms-side-panel').first()
.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`)
.first();
}
function getUnreadBadge(container: Locator): Locator {
return container.locator('span.rounded-full').first();
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}
interface WindowWithDesktopNotifications extends Window {
__desktopNotifications: DesktopNotificationRecord[];
}

View File

@@ -0,0 +1,712 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
chromium,
type BrowserContext,
type Page
} from '@playwright/test';
import { test, expect } from '../../fixtures/multi-client';
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
interface TestUser {
displayName: string;
password: string;
username: string;
}
interface AvatarUploadPayload {
buffer: Buffer;
dataUrl: string;
mimeType: string;
name: string;
}
interface PersistentClient {
context: BrowserContext;
page: Page;
user: TestUser;
userDataDir: string;
}
interface ProfileMetadata {
description?: string;
displayName: string;
}
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
const GIF_FRAME_MARKER = Buffer.from([
0x21,
0xF9,
0x04
]);
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
0x21,
0xFF,
0x0B,
0x4E,
0x45,
0x54,
0x53,
0x43,
0x41,
0x50,
0x45,
0x32,
0x2E,
0x30,
0x03,
0x01,
0x00,
0x00,
0x00
]);
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
const VOICE_CHANNEL = 'General';
const AVATAR_SYNC_TIMEOUT_MS = 45_000;
test.describe('Profile avatar sync', () => {
test.describe.configure({ timeout: 240_000 });
test('syncs avatar changes for online and late-joining users and persists after restart', async ({ testServer }) => {
const suffix = uniqueName('avatar');
const serverName = `Avatar Sync Server ${suffix}`;
const messageText = `Avatar sync message ${suffix}`;
const avatarA = buildAnimatedGifUpload('alpha');
const avatarB = buildAnimatedGifUpload('beta');
const aliceUser: TestUser = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const bobUser: TestUser = {
username: `bob_${suffix}`,
displayName: 'Bob',
password: 'TestPass123!'
};
const carolUser: TestUser = {
username: `carol_${suffix}`,
displayName: 'Carol',
password: 'TestPass123!'
};
const clients: PersistentClient[] = [];
try {
const alice = await createPersistentClient(aliceUser, testServer.port);
const bob = await createPersistentClient(bobUser, testServer.port);
clients.push(alice, bob);
await test.step('Alice and Bob register, create a server, and join the same room', async () => {
await registerUser(alice);
await registerUser(bob);
const aliceSearchPage = new ServerSearchPage(alice.page);
await aliceSearchPage.createServer(serverName, {
description: 'Avatar synchronization E2E coverage'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await joinServerFromSearch(bob.page, serverName);
await waitForRoomReady(alice.page);
await waitForRoomReady(bob.page);
await waitForConnectedPeerCount(alice.page, 1);
await waitForConnectedPeerCount(bob.page, 1);
await expectUserRowVisible(bob.page, aliceUser.displayName);
});
const roomUrl = alice.page.url();
await test.step('Alice uploads the first avatar while Bob is online and Bob sees it live', async () => {
await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarA);
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarA.dataUrl);
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarA.dataUrl);
});
await test.step('Alice sees the updated avatar in voice controls', async () => {
await ensureVoiceChannelExists(alice.page, VOICE_CHANNEL);
await joinVoiceChannel(alice.page, VOICE_CHANNEL);
await expectVoiceControlsAvatar(alice.page, avatarA.dataUrl);
});
const carol = await createPersistentClient(carolUser, testServer.port);
clients.push(carol);
await test.step('Carol joins after the first change and sees the updated avatar', async () => {
await registerUser(carol);
await joinServerFromSearch(carol.page, serverName);
await waitForRoomReady(carol.page);
await waitForConnectedPeerCount(alice.page, 2);
await waitForConnectedPeerCount(carol.page, 1);
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl);
});
await test.step('Alice avatar is used in chat messages for everyone in the room', async () => {
const aliceMessagesPage = new ChatMessagesPage(alice.page);
await aliceMessagesPage.sendMessage(messageText);
await expectChatMessageAvatar(alice.page, messageText, avatarA.dataUrl);
await expectChatMessageAvatar(bob.page, messageText, avatarA.dataUrl);
await expectChatMessageAvatar(carol.page, messageText, avatarA.dataUrl);
});
await test.step('Alice changes the avatar again and all three users see the update in real time', async () => {
await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarB);
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);
await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);
await expectVoiceControlsAvatar(alice.page, avatarB.dataUrl);
});
await test.step('Bob, Carol, and Alice each keep the updated avatar after a full app restart', async () => {
await restartPersistentClient(bob, testServer.port);
await openRoomAfterRestart(bob, roomUrl);
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);
await restartPersistentClient(carol, testServer.port);
await openRoomAfterRestart(carol, roomUrl);
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);
await restartPersistentClient(alice, testServer.port);
await openRoomAfterRestart(alice, roomUrl);
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
});
} finally {
await Promise.all(clients.map(async (client) => {
await closePersistentClient(client);
await rm(client.userDataDir, { recursive: true, force: true });
}));
}
});
});
test.describe('Profile metadata sync', () => {
test.describe.configure({ timeout: 240_000 });
test('syncs display name and description changes for online and late-joining users and persists after restart', async ({ testServer }) => {
const suffix = uniqueName('profile');
const serverName = `Profile Sync Server ${suffix}`;
const messageText = `Profile sync message ${suffix}`;
const firstProfile: ProfileMetadata = {
displayName: `Alice One ${suffix}`,
description: `First synced profile description ${suffix}`
};
const secondProfile: ProfileMetadata = {
displayName: `Alice Two ${suffix}`,
description: `Second synced profile description ${suffix}`
};
const aliceUser: TestUser = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const bobUser: TestUser = {
username: `bob_${suffix}`,
displayName: 'Bob',
password: 'TestPass123!'
};
const carolUser: TestUser = {
username: `carol_${suffix}`,
displayName: 'Carol',
password: 'TestPass123!'
};
const clients: PersistentClient[] = [];
try {
const alice = await createPersistentClient(aliceUser, testServer.port);
const bob = await createPersistentClient(bobUser, testServer.port);
clients.push(alice, bob);
await test.step('Alice and Bob register, create a server, and join the same room', async () => {
await registerUser(alice);
await registerUser(bob);
const aliceSearchPage = new ServerSearchPage(alice.page);
await aliceSearchPage.createServer(serverName, {
description: 'Profile synchronization E2E coverage'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await joinServerFromSearch(bob.page, serverName);
await waitForRoomReady(alice.page);
await waitForRoomReady(bob.page);
await waitForConnectedPeerCount(alice.page, 1);
await waitForConnectedPeerCount(bob.page, 1);
await expectUserRowVisible(bob.page, aliceUser.displayName);
});
const roomUrl = alice.page.url();
await test.step('Alice updates her profile while Bob is online and Bob sees it live', async () => {
await updateProfileFromRoomSidebar(alice.page, {
displayName: aliceUser.displayName
}, firstProfile);
await expectUserRowVisible(alice.page, firstProfile.displayName);
await expectUserRowVisible(bob.page, firstProfile.displayName);
await expectProfileCardDetails(bob.page, firstProfile);
});
const carol = await createPersistentClient(carolUser, testServer.port);
clients.push(carol);
await test.step('Carol joins after the first change and sees the updated profile', async () => {
await registerUser(carol);
await joinServerFromSearch(carol.page, serverName);
await waitForRoomReady(carol.page);
await waitForConnectedPeerCount(alice.page, 2);
await waitForConnectedPeerCount(carol.page, 1);
await expectUserRowVisible(carol.page, firstProfile.displayName);
await expectProfileCardDetails(carol.page, firstProfile);
});
await test.step('Alice changes her profile again and new chat messages use the latest display name', async () => {
await updateProfileFromRoomSidebar(alice.page, firstProfile, secondProfile);
await expectUserRowVisible(alice.page, secondProfile.displayName);
await expectUserRowVisible(bob.page, secondProfile.displayName);
await expectUserRowVisible(carol.page, secondProfile.displayName);
await expectProfileCardDetails(bob.page, secondProfile);
await expectProfileCardDetails(carol.page, secondProfile);
const aliceMessagesPage = new ChatMessagesPage(alice.page);
await aliceMessagesPage.sendMessage(messageText);
await expectChatMessageSenderName(alice.page, messageText, secondProfile.displayName);
await expectChatMessageSenderName(bob.page, messageText, secondProfile.displayName);
await expectChatMessageSenderName(carol.page, messageText, secondProfile.displayName);
});
await test.step('Bob, Carol, and Alice keep the latest profile after a full app restart', async () => {
await restartPersistentClient(bob, testServer.port);
await openRoomAfterRestart(bob, roomUrl);
await expectUserRowVisible(bob.page, secondProfile.displayName);
await expectProfileCardDetails(bob.page, secondProfile);
await restartPersistentClient(carol, testServer.port);
await openRoomAfterRestart(carol, roomUrl);
await expectUserRowVisible(carol.page, secondProfile.displayName);
await expectProfileCardDetails(carol.page, secondProfile);
await restartPersistentClient(alice, testServer.port);
await openRoomAfterRestart(alice, roomUrl);
await expectUserRowVisible(alice.page, secondProfile.displayName);
await expectProfileCardDetails(alice.page, secondProfile);
});
} finally {
await Promise.all(clients.map(async (client) => {
await closePersistentClient(client);
await rm(client.userDataDir, { recursive: true, force: true });
}));
}
});
});
async function createPersistentClient(user: TestUser, testServerPort: number): Promise<PersistentClient> {
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-avatar-e2e-'));
const session = await launchPersistentSession(userDataDir, testServerPort);
return {
context: session.context,
page: session.page,
user,
userDataDir
};
}
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
await closePersistentClient(client);
const session = await launchPersistentSession(client.userDataDir, testServerPort);
client.context = session.context;
client.page = session.page;
}
async function closePersistentClient(client: PersistentClient): Promise<void> {
try {
await client.context.close();
} catch {
// Ignore repeated cleanup attempts during finally.
}
}
async function launchPersistentSession(
userDataDir: string,
testServerPort: number
): Promise<{ context: BrowserContext; page: Page }> {
const context = await chromium.launchPersistentContext(userDataDir, {
args: CLIENT_LAUNCH_ARGS,
baseURL: 'http://localhost:4200',
permissions: ['microphone', 'camera']
});
await installTestServerEndpoint(context, testServerPort);
const page = context.pages()[0] ?? await context.newPage();
await installWebRTCTracking(page);
return { context, page };
}
async function registerUser(client: PersistentClient): Promise<void> {
const registerPage = new RegisterPage(client.page);
await retryTransientNavigation(() => registerPage.goto());
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
}
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
const searchPage = new ServerSearchPage(page);
await searchPage.joinServerFromSearch(serverName);
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
}
async function ensureVoiceChannelExists(page: Page, channelName: string): Promise<void> {
const chatRoom = new ChatRoomPage(page);
const existingVoiceChannel = page.locator('app-rooms-side-panel').getByRole('button', { name: channelName, exact: true });
if (await existingVoiceChannel.count() > 0) {
return;
}
await chatRoom.openCreateVoiceChannelDialog();
await chatRoom.createChannel(channelName);
await expect(existingVoiceChannel).toBeVisible({ timeout: 10_000 });
}
async function joinVoiceChannel(page: Page, channelName: string): Promise<void> {
const chatRoom = new ChatRoomPage(page);
await chatRoom.joinVoiceChannel(channelName);
await expect(page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
}
async function uploadAvatarFromRoomSidebar(
page: Page,
displayName: string,
avatar: AvatarUploadPayload
): Promise<void> {
const currentUserRow = getUserRow(page, displayName);
const profileFileInput = page.locator('app-profile-card input[type="file"]');
const applyButton = page.getByRole('button', { name: 'Apply picture' });
await expect(currentUserRow).toBeVisible({ timeout: 15_000 });
if (await profileFileInput.count() === 0) {
await currentUserRow.click();
await expect(profileFileInput).toBeAttached({ timeout: 10_000 });
}
await profileFileInput.setInputFiles({
name: avatar.name,
mimeType: avatar.mimeType,
buffer: avatar.buffer
});
await expect(applyButton).toBeVisible({ timeout: 10_000 });
await applyButton.click();
await expect(applyButton).not.toBeVisible({ timeout: 10_000 });
}
async function updateProfileFromRoomSidebar(
page: Page,
currentProfile: ProfileMetadata,
nextProfile: ProfileMetadata
): Promise<void> {
const profileCard = await openProfileCardFromUserRow(page, currentProfile.displayName);
const displayNameButton = profileCard.getByRole('button', { name: currentProfile.displayName, exact: true });
await expect(displayNameButton).toBeVisible({ timeout: 10_000 });
await displayNameButton.click();
const displayNameInput = profileCard.locator('input[type="text"]').first();
await expect(displayNameInput).toBeVisible({ timeout: 10_000 });
await displayNameInput.fill(nextProfile.displayName);
await displayNameInput.blur();
await expect(profileCard.locator('input[type="text"]')).toHaveCount(0, { timeout: 10_000 });
const currentDescriptionText = currentProfile.description || 'Add a description';
await profileCard.getByText(currentDescriptionText, { exact: true }).click();
const descriptionInput = profileCard.locator('textarea').first();
await expect(descriptionInput).toBeVisible({ timeout: 10_000 });
await descriptionInput.fill(nextProfile.description || '');
await descriptionInput.blur();
await expect(profileCard.locator('textarea')).toHaveCount(0, { timeout: 10_000 });
await expect(profileCard.getByText(nextProfile.displayName, { exact: true })).toBeVisible({ timeout: 10_000 });
if (nextProfile.description) {
await expect(profileCard.getByText(nextProfile.description, { exact: true })).toBeVisible({ timeout: 10_000 });
}
}
async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
if (client.page.url().includes('/login')) {
const loginPage = new LoginPage(client.page);
await loginPage.login(client.user.username, client.user.password);
await expect(client.page).toHaveURL(/\/(search|room)\//, { timeout: 15_000 });
await client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' });
}
await waitForRoomReady(client.page);
}
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await navigate();
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');
if (!isTransientNavigationError || attempt === attempts) {
throw error;
}
}
}
throw lastError instanceof Error
? lastError
: new Error(`Navigation failed after ${attempts} attempts`);
}
async function waitForRoomReady(page: Page): Promise<void> {
const messagesPage = new ChatMessagesPage(page);
await messagesPage.waitForReady();
await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
}
async function waitForConnectedPeerCount(page: Page, count: number, timeout = 30_000): Promise<void> {
await page.waitForFunction((expectedCount) => {
const connections = (window as {
__rtcConnections?: RTCPeerConnection[];
}).__rtcConnections ?? [];
return connections.filter((connection) => connection.connectionState === 'connected').length >= expectedCount;
}, count, { timeout });
}
async function openProfileCardFromUserRow(page: Page, displayName: string) {
await closeProfileCard(page);
const row = getUserRow(page, displayName);
await expect(row).toBeVisible({ timeout: 20_000 });
await row.click();
const profileCard = page.locator('app-profile-card');
await expect(profileCard).toBeVisible({ timeout: 10_000 });
return profileCard;
}
async function closeProfileCard(page: Page): Promise<void> {
const profileCard = page.locator('app-profile-card');
if (await profileCard.count() === 0) {
return;
}
try {
await expect(profileCard).toBeVisible({ timeout: 1_000 });
} catch {
return;
}
await page.mouse.click(8, 8);
await expect(profileCard).toHaveCount(0, { timeout: 10_000 });
}
function getUserRow(page: Page, displayName: string) {
const usersSidePanel = page.locator('app-rooms-side-panel').last();
return usersSidePanel.locator('[role="button"]').filter({
has: page.getByText(displayName, { exact: true })
})
.first();
}
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 });
}
async function expectProfileCardDetails(page: Page, profile: ProfileMetadata): Promise<void> {
const profileCard = await openProfileCardFromUserRow(page, profile.displayName);
await expect(profileCard.getByText(profile.displayName, { exact: true })).toBeVisible({ timeout: 20_000 });
if (profile.description) {
await expect(profileCard.getByText(profile.description, { exact: true })).toBeVisible({ timeout: 20_000 });
}
await closeProfileCard(page);
}
async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise<void> {
const row = getUserRow(page, displayName);
await expect(row).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const image = row.locator('img').first();
if (await image.count() === 0) {
return null;
}
return image.getAttribute('src');
}, {
timeout: AVATAR_SYNC_TIMEOUT_MS,
message: `${displayName} avatar src should update`
}).toBe(expectedDataUrl);
await expect.poll(async () => {
const image = row.locator('img').first();
if (await image.count() === 0) {
return false;
}
return image.evaluate((element) => {
const img = element as HTMLImageElement;
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
});
}, {
timeout: AVATAR_SYNC_TIMEOUT_MS,
message: `${displayName} avatar image should load`
}).toBe(true);
}
async function expectChatMessageAvatar(page: Page, messageText: string, expectedDataUrl: string): Promise<void> {
const messagesPage = new ChatMessagesPage(page);
const messageItem = messagesPage.getMessageItemByText(messageText);
await expect(messageItem).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const image = messageItem.locator('app-user-avatar img').first();
if (await image.count() === 0) {
return null;
}
return image.getAttribute('src');
}, {
timeout: AVATAR_SYNC_TIMEOUT_MS,
message: `Chat message avatar for "${messageText}" should update`
}).toBe(expectedDataUrl);
}
async function expectChatMessageSenderName(page: Page, messageText: string, expectedDisplayName: string): Promise<void> {
const messagesPage = new ChatMessagesPage(page);
const messageItem = messagesPage.getMessageItemByText(messageText);
await expect(messageItem).toBeVisible({ timeout: 20_000 });
await expect(messageItem.getByText(expectedDisplayName, { exact: true })).toBeVisible({ timeout: 20_000 });
}
async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise<void> {
const voiceControls = page.locator('app-voice-controls');
await expect(voiceControls).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const image = voiceControls.locator('app-user-avatar img').first();
if (await image.count() === 0) {
return null;
}
return image.getAttribute('src');
}, {
timeout: AVATAR_SYNC_TIMEOUT_MS,
message: 'Voice controls avatar should update'
}).toBe(expectedDataUrl);
}
function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
const baseGif = Buffer.from(STATIC_GIF_BASE64, 'base64');
const frameStart = baseGif.indexOf(GIF_FRAME_MARKER);
if (frameStart < 0) {
throw new Error('Failed to locate GIF frame marker for animated avatar payload');
}
const header = baseGif.subarray(0, frameStart);
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
const commentData = Buffer.from(label, 'ascii');
const commentExtension = Buffer.concat([
Buffer.from([
0x21,
0xFE,
commentData.length
]),
commentData,
Buffer.from([0x00])
]);
const buffer = Buffer.concat([
header,
NETSCAPE_LOOP_EXTENSION,
commentExtension,
frame,
frame,
Buffer.from([0x3B])
]);
const base64 = buffer.toString('base64');
return {
buffer,
dataUrl: `data:image/gif;base64,${base64}`,
mimeType: 'image/gif',
name: `animated-avatar-${label}.gif`
};
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -0,0 +1,503 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
chromium,
type BrowserContext,
type Locator,
type Page,
type Route
} from '@playwright/test';
import { test, expect } from '../../fixtures/multi-client';
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
interface TestUser {
displayName: string;
password: string;
username: string;
}
interface ImageUploadPayload {
buffer: Buffer;
dataUrl: string;
mimeType: string;
name: string;
}
interface PersistentClient {
context: BrowserContext;
page: Page;
user: TestUser;
userDataDir: string;
}
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
const GIF_FRAME_MARKER = Buffer.from([
0x21,
0xf9,
0x04
]);
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
const SERVER_ICON_SYNC_TIMEOUT_MS = 45_000;
test.describe('Server icon sync', () => {
test.describe.configure({ timeout: 240_000 });
test('loads the chat-server image for online, late-joining, restarted, and discovery users', async ({ testServer }) => {
const suffix = uniqueName('server-icon');
const serverName = `Icon Sync Server ${suffix}`;
const icon = buildGifUpload('server-icon');
const aliceUser: TestUser = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const bobUser: TestUser = {
username: `bob_${suffix}`,
displayName: 'Bob',
password: 'TestPass123!'
};
const carolUser: TestUser = {
username: `carol_${suffix}`,
displayName: 'Carol',
password: 'TestPass123!'
};
const daveUser: TestUser = {
username: `dave_${suffix}`,
displayName: 'Dave',
password: 'TestPass123!'
};
const clients: PersistentClient[] = [];
try {
const alice = await createPersistentClient(aliceUser, testServer.port);
const bob = await createPersistentClient(bobUser, testServer.port);
clients.push(alice, bob);
await test.step('Alice creates a server and Bob joins before the icon changes', async () => {
await registerUser(alice);
await registerUser(bob);
await new ServerSearchPage(alice.page).createServer(serverName, {
description: 'Server icon synchronization E2E coverage'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await joinServerFromSearch(bob.page, serverName);
await waitForRoomReady(alice.page);
await waitForRoomReady(bob.page);
await waitForConnectedPeerCount(alice.page, 1);
await waitForConnectedPeerCount(bob.page, 1);
});
const roomUrl = alice.page.url();
await test.step('Alice uploads a server icon and sees it in every owner-facing place', async () => {
await uploadServerIconFromSettings(alice.page, serverName, icon);
await expectServerSettingsIcon(alice.page, serverName, icon.dataUrl);
await closeSettingsModal(alice.page);
await expectRoomHeaderIcon(alice.page, serverName, icon.dataUrl);
await expectRailIcon(alice.page, serverName, icon.dataUrl);
});
await test.step('Bob was online during the change and receives the icon live', async () => {
await expectRoomHeaderIcon(bob.page, serverName, icon.dataUrl);
await expectRailIcon(bob.page, serverName, icon.dataUrl);
});
const carol = await createPersistentClient(carolUser, testServer.port);
clients.push(carol);
await test.step('Carol joins after the change and loads the existing server icon', async () => {
await registerUser(carol);
await joinServerFromSearch(carol.page, serverName);
await waitForRoomReady(carol.page);
await waitForConnectedPeerCount(alice.page, 2);
await expectRoomHeaderIcon(carol.page, serverName, icon.dataUrl);
await expectRailIcon(carol.page, serverName, icon.dataUrl);
});
await test.step('Bob keeps the server icon after a full app restart', async () => {
await restartPersistentClient(bob, testServer.port);
await openRoomAfterRestart(bob, roomUrl);
await expectRoomHeaderIcon(bob.page, serverName, icon.dataUrl);
await expectRailIcon(bob.page, serverName, icon.dataUrl);
});
const dave = await createPersistentClient(daveUser, testServer.port);
clients.push(dave);
await test.step('Dave has not joined, but discovery loads the icon through a temporary peer sync', async () => {
await registerUser(dave);
await stripServerIconFromDirectorySearch(dave.page, serverName);
await dave.page.goto('/search', { waitUntil: 'domcontentloaded' });
await new ServerSearchPage(dave.page).searchInput.fill(serverName);
await expectSearchResultIcon(dave.page, serverName, icon.dataUrl);
await expect(dave.page).toHaveURL(/\/search/);
});
} finally {
await Promise.all(
clients.map(async (client) => {
await closePersistentClient(client);
await rm(client.userDataDir, { recursive: true, force: true });
})
);
}
});
});
async function createPersistentClient(user: TestUser, testServerPort: number): Promise<PersistentClient> {
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-server-icon-e2e-'));
const session = await launchPersistentSession(userDataDir, testServerPort);
return {
context: session.context,
page: session.page,
user,
userDataDir
};
}
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
await closePersistentClient(client);
const session = await launchPersistentSession(client.userDataDir, testServerPort);
client.context = session.context;
client.page = session.page;
}
async function closePersistentClient(client: PersistentClient): Promise<void> {
try {
await client.context.close();
} catch {
// Ignore repeated cleanup attempts during finally.
}
}
async function launchPersistentSession(userDataDir: string, testServerPort: number): Promise<{ context: BrowserContext; page: Page }> {
const context = await chromium.launchPersistentContext(userDataDir, {
args: CLIENT_LAUNCH_ARGS,
baseURL: 'http://localhost:4200',
permissions: ['microphone', 'camera']
});
await installTestServerEndpoint(context, testServerPort);
const page = context.pages()[0] ?? (await context.newPage());
await installWebRTCTracking(page);
return { context, page };
}
async function registerUser(client: PersistentClient): Promise<void> {
const registerPage = new RegisterPage(client.page);
await retryTransientNavigation(() => registerPage.goto());
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
}
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
await new ServerSearchPage(page).joinServerFromSearch(serverName);
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
}
async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
if (client.page.url().includes('/login')) {
const loginPage = new LoginPage(client.page);
await loginPage.login(client.user.username, client.user.password);
await expect(client.page).toHaveURL(/\/(search|room)\//, { timeout: 15_000 });
await client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' });
}
await waitForRoomReady(client.page);
}
async function uploadServerIconFromSettings(page: Page, serverName: string, icon: ImageUploadPayload): Promise<void> {
await openServerSettings(page, serverName);
const fileInput = page.locator('#server-icon-upload');
await expect(fileInput).toBeAttached({ timeout: 10_000 });
await fileInput.setInputFiles({
name: icon.name,
mimeType: icon.mimeType,
buffer: icon.buffer
});
}
async function openServerSettings(page: Page, serverName: string): Promise<void> {
await page.locator('app-title-bar button[title="Menu"]').click();
const titleBarMenu = page.locator('app-title-bar .absolute.right-0.top-full').first();
await expect(titleBarMenu).toBeVisible({ timeout: 5_000 });
await titleBarMenu.getByRole('button', { name: 'Settings' }).click();
const dialog = page.locator('app-settings-modal');
const serverSettingsTitle = dialog.getByRole('heading', { name: 'Server Settings' });
try {
await expect(serverSettingsTitle).toBeVisible({ timeout: 2_000 });
} catch {
await openSettingsModalThroughAngularDevMode(page);
await expect(serverSettingsTitle).toBeVisible({ timeout: 10_000 });
}
const serverSelect = dialog.locator('select').first();
if ((await serverSelect.count()) > 0) {
await expect(serverSelect).toContainText(serverName, { timeout: 10_000 });
}
await dialog.getByRole('button', { name: 'Server', exact: true }).click();
await expect(page.locator('app-server-settings')).toBeVisible({ timeout: 10_000 });
}
async function openSettingsModalThroughAngularDevMode(page: Page): Promise<void> {
await page.evaluate(() => {
interface SettingsModalComponentHandle {
modal?: {
open: (page: string) => void;
};
}
interface AngularDebugApi {
getComponent: (element: Element) => SettingsModalComponentHandle;
applyChanges?: (component: SettingsModalComponentHandle) => void;
}
const host = document.querySelector('app-settings-modal');
const debugApi = (window as Window & { ng?: AngularDebugApi }).ng;
const component = host && debugApi?.getComponent(host);
if (!component?.modal?.open) {
throw new Error('Angular debug API could not open settings modal');
}
component.modal.open('server');
debugApi.applyChanges?.(component);
});
}
async function closeSettingsModal(page: Page): Promise<void> {
await page.keyboard.press('Escape');
await expect(page.locator('app-settings-modal').getByRole('heading', { name: 'Settings', exact: true })).not.toBeVisible({ timeout: 10_000 });
}
async function stripServerIconFromDirectorySearch(page: Page, serverName: string): Promise<void> {
await page.route('**/api/servers**', async (route: Route) => {
const response = await route.fetch();
const contentType = response.headers()['content-type'] ?? '';
if (!contentType.includes('application/json')) {
await route.fulfill({ response });
return;
}
const body = await response.json();
if (!body || !Array.isArray(body.servers)) {
await route.fulfill({ response, json: body });
return;
}
await route.fulfill({
response,
json: {
...body,
servers: body.servers.map((server: Record<string, unknown>) => {
if (server['name'] !== serverName) {
return server;
}
const { icon: _icon, ...serverWithoutIcon } = server;
return serverWithoutIcon;
})
}
});
});
}
async function waitForRoomReady(page: Page): Promise<void> {
const messagesPage = new ChatMessagesPage(page);
await messagesPage.waitForReady();
await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
}
async function waitForConnectedPeerCount(page: Page, count: number, timeout = 30_000): Promise<void> {
await page.waitForFunction(
(expectedCount) => {
const connections =
(
window as {
__rtcConnections?: RTCPeerConnection[];
}
).__rtcConnections ?? [];
return connections.filter((connection) => connection.connectionState === 'connected').length >= expectedCount;
},
count,
{ timeout }
);
}
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await navigate();
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');
if (!isTransientNavigationError || attempt === attempts) {
throw error;
}
}
}
throw lastError instanceof Error ? lastError : new Error(`Navigation failed after ${attempts} attempts`);
}
async function expectServerSettingsIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
const settingsPanel = page.locator('app-server-settings');
const image = settingsPanel.locator('[style*="background-image"]').first();
await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'settings server icon');
}
async function expectRoomHeaderIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
const channelsPanel = page.locator('app-rooms-side-panel').first();
const image = channelsPanel.locator('[style*="background-image"]').first();
await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'room header server icon');
}
async function expectRailIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
const image = page.locator(`app-servers-rail button[title="${serverName}"] [style*="background-image"]`).first();
await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'servers rail icon');
}
async function expectSearchResultIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
const serverCard = page.locator('app-server-search div[title]', { hasText: serverName }).first();
const image = serverCard.locator('[style*="background-image"]').first();
await expect(serverCard).toBeVisible({ timeout: 20_000 });
await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'search result server icon');
}
async function expectBackgroundImageLoadedWithUrl(image: Locator, expectedDataUrl: string, label: string): Promise<void> {
await expect
.poll(
async () => {
if ((await image.count()) === 0) {
return null;
}
return image.evaluate((element) => getComputedStyle(element).backgroundImage);
},
{
timeout: SERVER_ICON_SYNC_TIMEOUT_MS,
message: `${label} background should update`
}
)
.toContain(expectedDataUrl);
await expect
.poll(
async () => {
if ((await image.count()) === 0) {
return false;
}
return image.evaluate(
(element) =>
new Promise<boolean>((resolve) => {
const backgroundImage = getComputedStyle(element).backgroundImage;
const match = /^url\("?(.*?)"?\)$/.exec(backgroundImage);
const img = new Image();
if (!match?.[1]) {
resolve(false);
return;
}
img.onload = () => resolve(img.naturalWidth > 0 && img.naturalHeight > 0);
img.onerror = () => resolve(false);
img.src = match[1];
})
);
},
{
timeout: SERVER_ICON_SYNC_TIMEOUT_MS,
message: `${label} should load`
}
)
.toBe(true);
}
function buildGifUpload(label: string): ImageUploadPayload {
const baseGif = Buffer.from(STATIC_GIF_BASE64, 'base64');
const frameStart = baseGif.indexOf(GIF_FRAME_MARKER);
if (frameStart < 0) {
throw new Error('Failed to locate GIF frame marker for server icon payload');
}
const header = baseGif.subarray(0, frameStart);
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
const commentData = Buffer.from(label, 'ascii');
const commentExtension = Buffer.concat([
Buffer.from([
0x21,
0xfe,
commentData.length
]),
commentData,
Buffer.from([0x00])
]);
const buffer = Buffer.concat([
header,
commentExtension,
frame,
frame,
Buffer.from([0x3b])
]);
const base64 = buffer.toString('base64');
return {
buffer,
dataUrl: `data:image/gif;base64,${base64}`,
mimeType: 'image/gif',
name: `server-icon-${label}.gif`
};
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -0,0 +1,186 @@
import { type Page } from '@playwright/test';
import {
expect,
test,
type Client
} from '../../fixtures/multi-client';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
const PLUGIN_SOURCE_URL = 'http://localhost:4200/plugins/e2e-plugin-source.json';
const PLUGIN_TITLE = 'E2E All API Plugin';
const EDITED_MESSAGE = 'Plugin API edited message';
const ORIGINAL_MESSAGE = 'Plugin API original message';
const DELETED_MESSAGE = 'Plugin API deleted message';
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
const PLUGIN_BOT_MESSAGE = 'Plugin bot message from all-api fixture';
const CUSTOM_EMBED_TEXT = 'E2E custom embed: Plugin API custom embed';
const SOUND_BOARD_TEXT = 'E2E soundboard ready';
const SOUND_BOARD_LABEL = 'E2E Soundboard';
const SOUND_BOARD_PLAYED_MESSAGE = 'E2E soundboard played Airhorn to voice channel';
const VOICE_CHANNEL = 'Plugin Voice';
test.describe('Plugin API multi-user runtime', () => {
test.describe.configure({ timeout: 180_000 });
test('runs chat, embed, soundboard, and profile APIs between two users', async ({ createClient }) => {
const scenario = await createPluginApiScenario(createClient);
await test.step('Alice has the server plugin active', async () => {
await expect(soundboardComposerButton(scenario.alice.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
});
await test.step('Activate the server plugin for Bob as the embed/soundboard receiver', async () => {
await installGrantAndActivatePlugin(scenario.bob.page, false);
await closeSettingsModal(scenario.bob.page);
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
});
await test.step('Alice opens the plugin soundboard modal and plays a sound to voice', async () => {
await soundboardComposerButton(scenario.alice.page).click();
await expect(scenario.alice.page.getByRole('dialog', { name: SOUND_BOARD_LABEL })).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByTestId('e2e-soundboard-modal')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
await scenario.alice.page.getByRole('button', { name: 'Play airhorn to voice' }).click();
await expect(scenario.alice.page.getByTestId('e2e-soundboard-status')).toHaveText(SOUND_BOARD_PLAYED_MESSAGE, { timeout: 20_000 });
});
await test.step('Bob receives messages sent and edited by Alice through the plugin API', async () => {
await expect(scenario.bobMessages.getMessageItemByText(EDITED_MESSAGE)).toBeVisible({ timeout: 30_000 });
await expect(scenario.bobMessages.getMessageItemByText(ORIGINAL_MESSAGE)).toHaveCount(0);
await expect(scenario.bob.page.getByText('(edited)')).toBeVisible({ timeout: 20_000 });
});
await test.step('Bob sees plugin API deletion state and plugin-user messages', async () => {
await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE_CONTENT)).toBeVisible({ timeout: 30_000 });
await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE)).toHaveCount(0);
await expect(scenario.bobMessages.getMessageItemByText(PLUGIN_BOT_MESSAGE)).toBeVisible({ timeout: 30_000 });
await expect(scenario.bobMessages.getMessageItemByText(SOUND_BOARD_PLAYED_MESSAGE)).toBeVisible({ timeout: 30_000 });
});
await test.step('Bob renders Alice custom embed through the plugin embed API', async () => {
await expect(scenario.bob.page.getByTestId('plugin-message-embeds')).toContainText(CUSTOM_EMBED_TEXT, { timeout: 30_000 });
});
await test.step('Bob sees Alice profile name changed by the plugin API', async () => {
await expect(scenario.bobMessages.getMessageItemByText(EDITED_MESSAGE)).toContainText('Alice Plugin Renamed', { timeout: 30_000 });
});
});
});
interface PluginApiScenario {
alice: Client;
aliceRoom: ChatRoomPage;
bob: Client;
bobRoom: ChatRoomPage;
aliceMessages: ChatMessagesPage;
bobMessages: ChatMessagesPage;
}
async function createPluginApiScenario(createClient: () => Promise<Client>): Promise<PluginApiScenario> {
const suffix = uniqueName('plugin-api');
const serverName = `Plugin API Server ${suffix}`;
const alice = await createClient();
const bob = await createClient();
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, { description: 'Two-user plugin API E2E coverage' });
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 30_000 });
const aliceRoom = new ChatRoomPage(alice.page);
await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
await installGrantAndActivatePlugin(alice.page, true);
await closeSettingsModal(alice.page);
await expect(soundboardComposerButton(alice.page)).toBeVisible({ timeout: 20_000 });
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName, { acceptPluginDownloads: true });
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 });
const bobRoom = new ChatRoomPage(bob.page);
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(aliceRoom.voiceControls).toBeVisible({ timeout: 30_000 });
await expect(bobRoom.voiceControls).toBeVisible({ timeout: 30_000 });
const aliceMessages = new ChatMessagesPage(alice.page);
const bobMessages = new ChatMessagesPage(bob.page);
await aliceMessages.waitForReady();
await bobMessages.waitForReady();
await expect(alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' })).toBeVisible({ timeout: 30_000 });
await expect(bob.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Alice' })).toBeVisible({ timeout: 30_000 });
return {
alice,
aliceRoom,
bob,
bobRoom,
aliceMessages,
bobMessages
};
}
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, 'TestPass123!');
await expect(page).toHaveURL(/\/search/, { timeout: 30_000 });
}
async function installGrantAndActivatePlugin(page: Page, installFromStore: boolean): Promise<void> {
await page.getByRole('button', { name: 'Plugin Store' }).click();
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 });
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 20_000 });
if (installFromStore) {
await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL);
await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
await expect(page.getByRole('dialog', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Install and Activate' }).click();
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 });
}
await page.getByRole('button', { name: 'Manage Plugins' }).click();
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('article', { hasText: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
await page.locator('article', { hasText: PLUGIN_TITLE })
.getByRole('button', { name: 'Select' })
.click();
await page.getByRole('button', { name: 'Grant all requested' }).click();
await page.getByRole('button', { name: 'Activate ready plugins' }).click();
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('ready', { exact: true })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Logs' }).click();
await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 30_000 });
}
async function closeSettingsModal(page: Page): Promise<void> {
await page.keyboard.press('Escape');
await expect(page.getByTestId('plugin-manager')).toHaveCount(0);
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}
function soundboardComposerButton(page: Page) {
return page.locator('app-chat-message-composer')
.getByRole('button', { exact: true, name: SOUND_BOARD_LABEL });
}

View File

@@ -0,0 +1,93 @@
import { expect, test } from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
test.describe('Plugin manager UI', () => {
test.describe.configure({ timeout: 180_000 });
test('installs, grants, activates, and logs an all-API test plugin', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = Date.now();
const register = new RegisterPage(page);
const search = new ServerSearchPage(page);
await test.step('Register user and create server context', async () => {
await register.goto();
await register.register(`plugin_${suffix}`, 'Plugin Tester', 'TestPass123!');
await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
await search.createServer(`Plugin API Server ${suffix}`, {
description: 'Plugin manager UI E2E coverage'
});
await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 });
});
await test.step('Open visible Plugin Store button', async () => {
await page.getByRole('button', { name: 'Plugin Store' }).click();
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 10_000 });
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 10_000 });
});
await test.step('Install fixture plugin from source manifest', async () => {
await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json');
await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: 'Readme' }).click();
await expect(page.getByText('Fixture plugin for Playwright coverage.')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
const installDialog = page.getByRole('dialog', { name: 'E2E All API Plugin' });
await expect(installDialog).toBeVisible({ timeout: 10_000 });
await expect(installDialog.getByText('Install to server', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Install and Activate' }).click();
await expect(page.locator('article', { hasText: 'E2E All API Plugin' }).getByText('Installed')).toBeVisible({ timeout: 10_000 });
});
await test.step('Open plugin manager from the store page', async () => {
await page.getByRole('button', { name: 'Manage Plugins' }).click();
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId('plugin-manager').getByRole('heading', { name: 'Server plugins' })).toBeVisible();
await expect(page.getByText('E2E All API Plugin')).toBeVisible();
});
await test.step('Grant capabilities and activate runtime', async () => {
const manager = page.getByTestId('plugin-manager');
const pluginCard = manager.locator('article', { hasText: 'E2E All API Plugin' });
await manager.getByRole('button', { name: 'Installed' }).click();
await expect(pluginCard).toBeVisible({ timeout: 10_000 });
await pluginCard.getByRole('button', { name: 'Select' }).click();
await page.getByRole('button', { name: 'Grant all requested' }).click();
await page.getByRole('button', { name: 'Activate ready plugins' }).click();
await expect(page.locator('article', { hasText: 'E2E All API Plugin' }).getByText('ready', { exact: true })).toBeVisible({ timeout: 20_000 });
});
await test.step('Verify plugin exercised APIs through logs and extension points', async () => {
const manager = page.getByTestId('plugin-manager');
await manager.getByRole('button', { name: 'Logs' }).click();
await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 20_000 });
await expect(page.getByText('all-api plugin ready')).toBeVisible({ timeout: 10_000 });
await manager.getByRole('button', { name: 'Extension points' }).click();
await expect(page.getByTestId('plugin-extension-counts')).toContainText('Settings pages');
await expect(page.getByTestId('plugin-extension-counts')).toContainText('Embed renderers');
await expect(page.getByTestId('plugin-extension-counts')).toContainText('1');
await expect(page.getByTestId('plugin-conflict-diagnostics')).toContainText(
'No duplicate route, action, embed, channel, panel, or settings contribution ids detected.'
);
await manager.getByRole('button', { exact: true, name: 'Requirements' }).click();
await expect(page.getByTestId('plugin-server-requirements')).toContainText('E2E All API Plugin');
await expect(page.getByTestId('plugin-server-requirements')).toContainText('enabled');
await manager.getByRole('button', { exact: true, name: 'Settings' }).click();
await expect(page.getByTestId('plugin-generated-settings')).toContainText('E2E settings contribution');
await expect(page.getByTestId('plugin-generated-settings')).toContainText('"enabled"');
await manager.getByRole('button', { exact: true, name: 'Docs' }).click();
await expect(page.getByTestId('plugin-installed-docs')).toContainText('Calls every public Toju plugin API surface');
});
});
});

View File

@@ -0,0 +1,369 @@
import type { APIRequestContext, APIResponse } from '@playwright/test';
import WebSocket from 'ws';
import { expect, test } from '../../fixtures/multi-client';
import {
getPluginApiTestEvent,
readPluginApiTestManifest,
TEST_PLUGIN_ID,
TEST_PLUGIN_P2P_EVENT,
TEST_PLUGIN_RELAY_EVENT
} from '../../helpers/plugin-api-test-fixture';
const OWNER_USER_ID = 'plugin-api-owner';
interface CreatedServerResponse {
id: string;
}
interface PluginRequirementResponse {
requirement: {
pluginId: string;
reason?: string;
status: string;
versionRange?: string;
};
}
interface PluginEventDefinitionResponse {
eventDefinition: {
direction: string;
eventName: string;
maxPayloadBytes: number;
pluginId: string;
scope: string;
};
}
interface PluginSnapshotResponse {
eventDefinitions: PluginEventDefinitionResponse['eventDefinition'][];
requirements: PluginRequirementResponse['requirement'][];
serverId: string;
}
interface SocketMessage {
[key: string]: unknown;
type?: string;
}
interface TestSocket {
close: () => Promise<void>;
messages: SocketMessage[];
send: (message: SocketMessage) => void;
}
test.describe('Plugin support API', () => {
test('covers plugin requirement, event, data, and websocket APIs with the fixture plugin', async ({ request, testServer }) => {
const manifest = await readPluginApiTestManifest();
const server = await createServer(request, testServer.url, `Plugin API ${Date.now()}`);
const relayEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_RELAY_EVENT);
const p2pEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_P2P_EVENT);
const pluginsApi = `${testServer.url}/api/servers/${encodeURIComponent(server.id)}/plugins`;
await test.step('Initial snapshot is empty', async () => {
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
expect(snapshot).toEqual(expect.objectContaining({
eventDefinitions: [],
requirements: [],
serverId: server.id
}));
});
await test.step('Requirement API enforces server management permission', async () => {
const response = await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
data: {
actorUserId: 'not-the-owner',
status: 'required'
}
});
const body = await expectJson<{ errorCode: string }>(response, 403);
expect(body.errorCode).toBe('NOT_AUTHORIZED');
});
await test.step('Requirement and event definition APIs persist the test plugin contract', async () => {
const requirement = await expectJson<PluginRequirementResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
data: {
actorUserId: OWNER_USER_ID,
reason: manifest.description,
status: 'required',
versionRange: `^${manifest.version}`
}
}));
expect(requirement.requirement).toEqual(expect.objectContaining({
pluginId: TEST_PLUGIN_ID,
reason: manifest.description,
status: 'required',
versionRange: `^${manifest.version}`
}));
const relayDefinition = await upsertEventDefinition(request, pluginsApi, relayEvent);
const p2pDefinition = await upsertEventDefinition(request, pluginsApi, p2pEvent);
expect(relayDefinition.eventDefinition).toEqual(expect.objectContaining({
direction: 'serverRelay',
eventName: TEST_PLUGIN_RELAY_EVENT,
pluginId: TEST_PLUGIN_ID,
scope: 'server'
}));
expect(p2pDefinition.eventDefinition).toEqual(expect.objectContaining({
direction: 'p2pHint',
eventName: TEST_PLUGIN_P2P_EVENT,
pluginId: TEST_PLUGIN_ID,
scope: 'user'
}));
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
expect(snapshot.requirements.map((entry) => entry.pluginId)).toEqual([TEST_PLUGIN_ID]);
expect(snapshot.eventDefinitions.map((entry) => entry.eventName).sort()).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]);
});
await test.step('Plugin data API refuses arbitrary server persistence', async () => {
const stored = await expectJson<{ errorCode: string }>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
data: {
actorUserId: OWNER_USER_ID,
schemaVersion: 1,
scope: 'server',
value: {
enabled: true,
pluginVersion: manifest.version
}
}
}), 410);
expect(stored.errorCode).toBe('PLUGIN_DATA_DISABLED');
const listed = await expectJson<{ errorCode: string }>(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, {
params: {
key: 'settings',
scope: 'server',
userId: OWNER_USER_ID
}
}), 410);
expect(listed.errorCode).toBe('PLUGIN_DATA_DISABLED');
const afterDelete = await expectJson<{ errorCode: string }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
data: {
actorUserId: OWNER_USER_ID,
scope: 'server'
}
}), 410);
expect(afterDelete.errorCode).toBe('PLUGIN_DATA_DISABLED');
});
await test.step('WebSocket plugin API sends snapshots, relays server events, and rejects p2p relays', async () => {
const alice = await openTestSocket(testServer.url);
const bob = await openTestSocket(testServer.url);
try {
alice.send({ type: 'identify', oderId: OWNER_USER_ID, displayName: 'Plugin Owner' });
bob.send({ type: 'identify', oderId: 'plugin-api-peer', displayName: 'Plugin Peer' });
alice.send({ type: 'join_server', serverId: server.id });
bob.send({ type: 'join_server', serverId: server.id });
const aliceSnapshot = await waitForSocketMessage(alice, (message) => message.type === 'plugin_requirements');
const bobSnapshot = await waitForSocketMessage(bob, (message) => message.type === 'plugin_requirements');
const bobEventNames = (bobSnapshot['snapshot'] as PluginSnapshotResponse).eventDefinitions
.map((entry) => entry.eventName)
.sort();
expect((aliceSnapshot['snapshot'] as PluginSnapshotResponse).requirements[0]?.pluginId).toBe(TEST_PLUGIN_ID);
expect(bobEventNames).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]);
alice.send({
type: 'plugin_event',
eventId: 'relay-event-1',
eventName: TEST_PLUGIN_RELAY_EVENT,
payload: { message: 'hello from fixture plugin' },
pluginId: TEST_PLUGIN_ID,
serverId: server.id,
sourcePluginUserId: 'fixture-plugin-user'
});
const relayedEvent = await waitForSocketMessage(bob, (message) => message.type === 'plugin_event');
expect(relayedEvent).toEqual(expect.objectContaining({
eventId: 'relay-event-1',
eventName: TEST_PLUGIN_RELAY_EVENT,
pluginId: TEST_PLUGIN_ID,
serverId: server.id,
sourcePluginUserId: 'fixture-plugin-user',
sourceUserId: OWNER_USER_ID
}));
expect(relayedEvent['payload']).toEqual({ message: 'hello from fixture plugin' });
expect(typeof relayedEvent['emittedAt']).toBe('number');
alice.send({
type: 'plugin_event',
eventId: 'p2p-event-1',
eventName: TEST_PLUGIN_P2P_EVENT,
payload: { hint: true },
pluginId: TEST_PLUGIN_ID,
serverId: server.id
});
const p2pError = await waitForSocketMessage(
alice,
(message) => message.type === 'plugin_error' && message['eventId'] === 'p2p-event-1'
);
expect(p2pError['code']).toBe('PLUGIN_EVENT_NOT_RELAYABLE');
alice.send({
type: 'plugin_event',
eventId: 'missing-event-1',
eventName: 'e2e:missing',
payload: {},
pluginId: TEST_PLUGIN_ID,
serverId: server.id
});
const missingError = await waitForSocketMessage(
alice,
(message) => message.type === 'plugin_error' && message['eventId'] === 'missing-event-1'
);
expect(missingError['code']).toBe('PLUGIN_EVENT_NOT_REGISTERED');
} finally {
await Promise.all([alice.close(), bob.close()]);
}
});
await test.step('Delete APIs remove event definitions and requirements', async () => {
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_RELAY_EVENT}`, {
data: { actorUserId: OWNER_USER_ID }
}));
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_P2P_EVENT}`, {
data: { actorUserId: OWNER_USER_ID }
}));
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
data: { actorUserId: OWNER_USER_ID }
}));
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
expect(snapshot.eventDefinitions).toEqual([]);
expect(snapshot.requirements).toEqual([]);
});
});
});
async function createServer(
request: APIRequestContext,
baseUrl: string,
serverName: string
): Promise<CreatedServerResponse> {
const response = await request.post(`${baseUrl}/api/servers`, {
data: {
channels: [
{
id: 'general-text',
name: 'general',
position: 0,
type: 'text'
}
],
description: 'Server for plugin API E2E coverage',
id: `plugin-api-${Date.now()}`,
isPrivate: false,
name: serverName,
ownerId: OWNER_USER_ID,
ownerPublicKey: 'plugin-api-owner-public-key',
tags: ['plugins']
}
});
return await expectJson<CreatedServerResponse>(response, 201);
}
async function upsertEventDefinition(
request: APIRequestContext,
pluginsApi: string,
eventDefinition: ReturnType<typeof getPluginApiTestEvent>
): Promise<PluginEventDefinitionResponse> {
return await expectJson<PluginEventDefinitionResponse>(await request.put(
`${pluginsApi}/${TEST_PLUGIN_ID}/events/${encodeURIComponent(eventDefinition.eventName)}`,
{
data: {
actorUserId: OWNER_USER_ID,
direction: eventDefinition.direction,
maxPayloadBytes: eventDefinition.maxPayloadBytes,
schemaJson: '{"type":"object"}',
scope: eventDefinition.scope
}
}
));
}
async function expectJson<T>(response: APIResponse, status = 200): Promise<T> {
expect(response.status()).toBe(status);
return await response.json() as T;
}
async function openTestSocket(baseUrl: string): Promise<TestSocket> {
const socketUrl = baseUrl.replace(/^http/, 'ws');
const socket = new WebSocket(socketUrl);
const messages: SocketMessage[] = [];
socket.on('message', (data) => {
messages.push(JSON.parse(data.toString()) as SocketMessage);
});
await new Promise<void>((resolve, reject) => {
socket.once('open', () => resolve());
socket.once('error', reject);
});
await waitForSocketMessage({ messages, send: () => {}, close: async () => {} }, (message) => message.type === 'connected');
return {
close: async () => {
if (socket.readyState === WebSocket.CLOSED) {
return;
}
await new Promise<void>((resolve) => {
socket.once('close', () => resolve());
socket.close();
});
},
messages,
send: (message: SocketMessage) => {
socket.send(JSON.stringify(message));
}
};
}
async function waitForSocketMessage(
socket: Pick<TestSocket, 'messages'>,
predicate: (message: SocketMessage) => boolean,
timeoutMs = 10_000
): Promise<SocketMessage> {
const startedAt = Date.now();
return await new Promise((resolve, reject) => {
const interval = setInterval(() => {
const message = socket.messages.find(predicate);
if (message) {
clearInterval(interval);
resolve(message);
return;
}
if (Date.now() - startedAt > timeoutMs) {
clearInterval(interval);
reject(new Error('Timed out waiting for websocket message'));
}
}, 25);
});
}

View File

@@ -0,0 +1,456 @@
import { test, expect } from '../../fixtures/multi-client';
import {
installWebRTCTracking,
waitForPeerConnected,
isPeerStillConnected,
waitForAudioFlow,
waitForAudioStatsPresent,
waitForVideoFlow,
waitForOutboundVideoFlow,
waitForInboundVideoFlow,
dumpRtcDiagnostics,
installAutoResumeAudioContext
} 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.joinServerFromSearch(SERVER_NAME);
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 });
}
const bobRoom = new ChatRoomPage(bob.page);
const doJoin = async () => {
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
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);
};
await doJoin();
// Chromium's --use-fake-device-for-media-stream can produce a silent
// capture track on the very first getUserMedia call. If bidirectional
// audio doesn't flow within a short window, leave and rejoin voice to
// re-acquire the mic (second getUserMedia on a warm device works).
const aliceDelta = await waitForAudioFlow(alice.page, 10_000);
const bobDelta = await waitForAudioFlow(bob.page, 10_000);
const aliceFlowing =
(aliceDelta.outboundBytesDelta > 0 || aliceDelta.outboundPacketsDelta > 0) &&
(aliceDelta.inboundBytesDelta > 0 || aliceDelta.inboundPacketsDelta > 0);
const bobFlowing =
(bobDelta.outboundBytesDelta > 0 || bobDelta.outboundPacketsDelta > 0) &&
(bobDelta.inboundBytesDelta > 0 || bobDelta.inboundPacketsDelta > 0);
if (!aliceFlowing || !bobFlowing) {
// Leave voice
await aliceRoom.disconnectButton.click();
await bobRoom.disconnectButton.click();
await alice.page.waitForTimeout(2_000);
// Rejoin
await doJoin();
}
// 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);
await installAutoResumeAudioContext(alice.page);
await installAutoResumeAudioContext(bob.page);
// Seed deterministic voice settings so noise reduction doesn't
// swallow the fake audio tone.
const voiceSettings = JSON.stringify({
inputVolume: 100, outputVolume: 100, audioBitrate: 96,
latencyProfile: 'balanced', includeSystemAudio: false,
noiseReduction: false, screenShareQuality: 'balanced',
askScreenShareQuality: false
});
await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
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);
await installAutoResumeAudioContext(alice.page);
await installAutoResumeAudioContext(bob.page);
const voiceSettings = JSON.stringify({
inputVolume: 100, outputVolume: 100, audioBitrate: 96,
latencyProfile: 'balanced', includeSystemAudio: false,
noiseReduction: false, screenShareQuality: 'balanced',
askScreenShareQuality: false
});
await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
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);
await installAutoResumeAudioContext(alice.page);
await installAutoResumeAudioContext(bob.page);
const voiceSettings = JSON.stringify({
inputVolume: 100, outputVolume: 100, audioBitrate: 96,
latencyProfile: 'balanced', includeSystemAudio: false,
noiseReduction: false, screenShareQuality: 'balanced',
askScreenShareQuality: false
});
await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
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 });
});
});
});

View File

@@ -0,0 +1,219 @@
import { test, expect } 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 {
installAutoResumeAudioContext,
installWebRTCTracking,
waitForConnectedPeerCount
} from '../../helpers/webrtc-helpers';
const VOICE_SETTINGS = JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
});
/**
* Seed deterministic voice settings on a page so noise reduction and
* input gating don't interfere with the fake audio tone.
*/
async function seedVoiceSettings(page: import('@playwright/test').Page): Promise<void> {
await page.addInitScript((settings: string) => {
localStorage.setItem('metoyou_voice_settings', settings);
}, VOICE_SETTINGS);
}
/**
* Close all of a client's RTCPeerConnections and prevent any
* reconnection by sabotaging the SDP negotiation methods on the
* prototype - new connections get created but can never complete ICE.
*
* Chromium doesn't fire `connectionstatechange` on programmatic
* `close()`, so we dispatch the event manually so the app's recovery
* code runs and updates the connected-peers signal.
*/
async function killAndBlockPeerConnections(page: import('@playwright/test').Page): Promise<void> {
await page.evaluate(() => {
// Sabotage SDP methods so no NEW connections can negotiate.
const proto = RTCPeerConnection.prototype;
proto.createOffer = () => Promise.reject(new DOMException('blocked', 'NotAllowedError'));
proto.createAnswer = () => Promise.reject(new DOMException('blocked', 'NotAllowedError'));
proto.setLocalDescription = () => Promise.reject(new DOMException('blocked', 'NotAllowedError'));
proto.setRemoteDescription = () => Promise.reject(new DOMException('blocked', 'NotAllowedError'));
// Close every existing connection and manually fire the event
// Chromium omits when close() is called from JS.
const connections = (window as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? [];
for (const pc of connections) {
try {
pc.close();
pc.dispatchEvent(new Event('connectionstatechange'));
} catch { /* already closed */ }
}
});
}
test.describe('Connectivity warning', () => {
test.describe.configure({ timeout: 180_000 });
test('shows warning icon when a peer loses all connections', async ({ createClient }) => {
const suffix = `connwarn_${Date.now()}`;
const serverName = `ConnWarn ${suffix}`;
const alice = await createClient();
const bob = await createClient();
const charlie = await createClient();
// ── Install WebRTC tracking & AudioContext auto-resume ──
for (const client of [
alice,
bob,
charlie
]) {
await installWebRTCTracking(client.page);
await installAutoResumeAudioContext(client.page);
await seedVoiceSettings(client.page);
}
// ── Register all three users ──
await test.step('Register Alice', async () => {
const register = new RegisterPage(alice.page);
await register.goto();
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
});
await test.step('Register Bob', async () => {
const register = new RegisterPage(bob.page);
await register.goto();
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
});
await test.step('Register Charlie', async () => {
const register = new RegisterPage(charlie.page);
await register.goto();
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
await expect(charlie.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
});
// ── Create server and have everyone join ──
await test.step('Alice creates a server', async () => {
const search = new ServerSearchPage(alice.page);
await search.createServer(serverName);
});
await test.step('Bob joins the server', async () => {
const search = new ServerSearchPage(bob.page);
await search.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
await test.step('Charlie joins the server', async () => {
const search = new ServerSearchPage(charlie.page);
await search.joinServerFromSearch(serverName);
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
const charlieRoom = new ChatRoomPage(charlie.page);
// ── Everyone joins voice ──
await test.step('All three join voice', async () => {
await aliceRoom.joinVoiceChannel('General');
await bobRoom.joinVoiceChannel('General');
await charlieRoom.joinVoiceChannel('General');
});
await test.step('All users see each other in voice', async () => {
// Each user should see the other two in the voice channel list.
await expect(
aliceRoom.channelsSidePanel.getByText('Bob')
).toBeVisible({ timeout: 20_000 });
await expect(
aliceRoom.channelsSidePanel.getByText('Charlie')
).toBeVisible({ timeout: 20_000 });
await expect(
bobRoom.channelsSidePanel.getByText('Alice')
).toBeVisible({ timeout: 20_000 });
await expect(
bobRoom.channelsSidePanel.getByText('Charlie')
).toBeVisible({ timeout: 20_000 });
await expect(
charlieRoom.channelsSidePanel.getByText('Alice')
).toBeVisible({ timeout: 20_000 });
await expect(
charlieRoom.channelsSidePanel.getByText('Bob')
).toBeVisible({ timeout: 20_000 });
});
// ── Wait for full mesh to establish ──
await test.step('All peer connections establish', async () => {
// Each client should have 2 connected peers (full mesh of 3).
await waitForConnectedPeerCount(alice.page, 2, 30_000);
await waitForConnectedPeerCount(bob.page, 2, 30_000);
await waitForConnectedPeerCount(charlie.page, 2, 30_000);
});
// ── Break Charlie's connections ──
await test.step('Kill Charlie peer connections and block reconnection', async () => {
await killAndBlockPeerConnections(charlie.page);
// Give the health service time to detect the desync.
// Peer latency pings stop -> connectedPeers updates -> desyncPeerIds recalculates.
await alice.page.waitForTimeout(15_000);
});
// ── Assert connectivity warnings ──
//
// The warning icon (lucideAlertTriangle) is a direct sibling of the
// user-name span inside the same voice-row div. Using the CSS
// general-sibling combinator (~) avoids accidentally matching a
// parent container that holds multiple rows.
await test.step('Alice sees warning icon next to Charlie', async () => {
const charlieWarning = aliceRoom.channelsSidePanel
.locator('span.truncate:has-text("Charlie") ~ ng-icon[name="lucideAlertTriangle"]');
await expect(charlieWarning).toBeVisible({ timeout: 30_000 });
});
await test.step('Bob sees warning icon next to Charlie', async () => {
const charlieWarning = bobRoom.channelsSidePanel
.locator('span.truncate:has-text("Charlie") ~ ng-icon[name="lucideAlertTriangle"]');
await expect(charlieWarning).toBeVisible({ timeout: 30_000 });
});
await test.step('Alice does NOT see warning icon next to Bob', async () => {
const bobWarning = aliceRoom.channelsSidePanel
.locator('span.truncate:has-text("Bob") ~ ng-icon[name="lucideAlertTriangle"]');
await expect(bobWarning).not.toBeVisible();
});
await test.step('Charlie sees local desync banner', async () => {
const desyncBanner = charlie.page.locator('text=You may have connectivity issues');
await expect(desyncBanner).toBeVisible({ timeout: 30_000 });
});
});
});

View File

@@ -0,0 +1,127 @@
import { test, expect } from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
test.describe('ICE server settings', () => {
test.describe.configure({ timeout: 120_000 });
async function registerAndOpenNetworkSettings(page: import('@playwright/test').Page, suffix: string) {
const register = new RegisterPage(page);
await register.goto();
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
await page.getByTitle('Settings').click();
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Network' }).click();
await expect(page.getByTestId('ice-server-settings')).toBeVisible({ timeout: 10_000 });
}
test('allows adding, removing, and reordering ICE servers', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = `ice_${Date.now()}`;
await test.step('Register and open Network settings', async () => {
await registerAndOpenNetworkSettings(page, suffix);
});
const iceSection = page.getByTestId('ice-server-settings');
await test.step('Default STUN servers are listed', async () => {
await expect(iceSection).toBeVisible({ timeout: 5_000 });
const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]');
await expect(entries.first()).toBeVisible({ timeout: 5_000 });
const count = await entries.count();
expect(count).toBeGreaterThanOrEqual(1);
});
await test.step('Add a STUN server', async () => {
await page.getByTestId('ice-type-select').selectOption('stun');
await page.getByTestId('ice-url-input').fill('stun:custom.example.com:3478');
await page.getByTestId('ice-add-button').click();
await expect(page.getByText('stun:custom.example.com:3478')).toBeVisible({ timeout: 5_000 });
});
await test.step('Add a TURN server with credentials', async () => {
await page.getByTestId('ice-type-select').selectOption('turn');
await page.getByTestId('ice-url-input').fill('turn:relay.example.com:443');
await page.getByTestId('ice-username-input').fill('testuser');
await page.getByTestId('ice-credential-input').fill('testpass');
await page.getByTestId('ice-add-button').click();
await expect(page.getByText('turn:relay.example.com:443')).toBeVisible({ timeout: 5_000 });
await expect(page.getByText('User: testuser')).toBeVisible({ timeout: 5_000 });
});
await test.step('Remove first entry and verify count decreases', async () => {
const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]');
const countBefore = await entries.count();
await entries.first().getByTitle('Remove')
.click();
await expect(entries).toHaveCount(countBefore - 1, { timeout: 5_000 });
});
await test.step('Reorder: move second entry up', async () => {
const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]');
const count = await entries.count();
if (count >= 2) {
const secondText = await entries.nth(1).locator('p')
.first()
.textContent();
if (!secondText) {
throw new Error('Expected ICE server entry text before reordering');
}
await entries.nth(1).getByTitle('Move up (higher priority)')
.click();
// Wait for the moved entry text to appear at position 0
await expect(entries.first().locator('p')
.first()).toHaveText(secondText, { timeout: 5_000 });
}
});
await test.step('Restore defaults resets list', async () => {
await page.getByTestId('ice-restore-defaults').click();
await expect(page.getByText('turn:relay.example.com:443')).not.toBeVisible({ timeout: 3_000 });
const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]');
await expect(entries.first()).toBeVisible({ timeout: 5_000 });
});
await test.step('Settings persist after page reload', async () => {
await page.getByTestId('ice-type-select').selectOption('stun');
await page.getByTestId('ice-url-input').fill('stun:persist-test.example.com:3478');
await page.getByTestId('ice-add-button').click();
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 5_000 });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.getByTitle('Settings').click();
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Network' }).click();
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });
});
});
test('validates TURN entries require credentials', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = `iceval_${Date.now()}`;
await test.step('Register and open Network settings', async () => {
await registerAndOpenNetworkSettings(page, suffix);
});
await test.step('Adding TURN without credentials shows error', async () => {
await page.getByTestId('ice-type-select').selectOption('turn');
await page.getByTestId('ice-url-input').fill('turn:noncred.example.com:443');
await page.getByTestId('ice-add-button').click();
await expect(page.getByText('Username is required for TURN servers')).toBeVisible({ timeout: 5_000 });
});
});
});

View File

@@ -0,0 +1,212 @@
import { test, expect } 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 {
dumpRtcDiagnostics,
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForPeerConnected,
waitForConnectedPeerCount,
waitForAudioStatsPresent
} from '../../helpers/webrtc-helpers';
const ICE_STORAGE_KEY = 'metoyou_ice_servers';
interface StoredIceServerEntry {
type?: string;
urls?: string;
}
/**
* Tests that user-configured ICE servers are persisted and used by peer connections.
*
* On localhost TURN relay is never needed (direct always succeeds), so this test:
* 1. Seeds Bob's browser with an additional TURN entry via localStorage.
* 2. Has both users join voice with differing ICE configs.
* 3. Verifies both can connect and Bob's TURN entry is still in storage.
*/
test.describe('STUN/TURN fallback behaviour', () => {
test.describe.configure({ timeout: 180_000 });
test('users with different ICE configs can voice chat together', async ({ createClient }) => {
const suffix = `turnfb_${Date.now()}`;
const serverName = `Fallback ${suffix}`;
const alice = await createClient();
const bob = await createClient();
// Install WebRTC tracking before any navigation so we can inspect
// peer connections and audio stats.
await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page);
// Ensure AudioContexts auto-resume so the input-gain pipeline
// (source -> gain -> destination) never stalls in "suspended" state.
await installAutoResumeAudioContext(alice.page);
await installAutoResumeAudioContext(bob.page);
// Set deterministic voice settings so noise reduction and input gating
// don't swallow the fake audio tone.
const voiceSettings = JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
});
await alice.page.addInitScript((settings: string) => {
localStorage.setItem('metoyou_voice_settings', settings);
}, voiceSettings);
await bob.page.addInitScript((settings: string) => {
localStorage.setItem('metoyou_voice_settings', settings);
}, voiceSettings);
// Seed Bob with an extra TURN entry before the app reads localStorage.
await bob.context.addInitScript((key: string) => {
try {
const existing = JSON.parse(localStorage.getItem(key) || '[]');
existing.push({
id: 'e2e-turn',
type: 'turn',
urls: 'turn:localhost:3478',
username: 'e2euser',
credential: 'e2epass'
});
localStorage.setItem(key, JSON.stringify(existing));
} catch { /* noop */ }
}, ICE_STORAGE_KEY);
await test.step('Register Alice', async () => {
const register = new RegisterPage(alice.page);
await register.goto();
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
});
await test.step('Register Bob', async () => {
const register = new RegisterPage(bob.page);
await register.goto();
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
});
await test.step('Alice creates a server', async () => {
const search = new ServerSearchPage(alice.page);
await search.createServer(serverName);
});
await test.step('Bob joins Alice server', async () => {
const search = new ServerSearchPage(bob.page);
await search.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
await test.step('Both join voice', async () => {
await aliceRoom.joinVoiceChannel('General');
await bobRoom.joinVoiceChannel('General');
});
await test.step('Both users see each other in voice', async () => {
await expect(
aliceRoom.channelsSidePanel.getByText('Bob')
).toBeVisible({ timeout: 20_000 });
await expect(
bobRoom.channelsSidePanel.getByText('Alice')
).toBeVisible({ timeout: 20_000 });
});
await test.step('Peer connections establish and audio flows bidirectionally', async () => {
await waitForPeerConnected(alice.page, 30_000);
await waitForPeerConnected(bob.page, 30_000);
await waitForConnectedPeerCount(alice.page, 1, 30_000);
await waitForConnectedPeerCount(bob.page, 1, 30_000);
// Wait for audio RTP stats to appear (tracks negotiated)
await waitForAudioStatsPresent(alice.page, 30_000);
await waitForAudioStatsPresent(bob.page, 30_000);
// Allow mesh to settle - voice routing and renegotiation can
// cause a second offer/answer cycle after the initial connection.
await alice.page.waitForTimeout(5_000);
// Chromium's --use-fake-device-for-media-stream can produce a
// silent capture track on the very first getUserMedia call. If
// bidirectional audio does not flow within a short window, leave
// and rejoin voice to re-acquire the mic (the second getUserMedia
// on a warm device always works).
let audioFlowing = false;
try {
await Promise.all([waitForAllPeerAudioFlow(alice.page, 1, 15_000), waitForAllPeerAudioFlow(bob.page, 1, 15_000)]);
audioFlowing = true;
} catch {
// Silent sender detected - rejoin voice to work around Chromium bug
}
if (!audioFlowing) {
// Leave voice
await aliceRoom.disconnectButton.click();
await bobRoom.disconnectButton.click();
await alice.page.waitForTimeout(2_000);
// Rejoin
await aliceRoom.joinVoiceChannel('General');
await bobRoom.joinVoiceChannel('General');
await expect(
aliceRoom.channelsSidePanel.getByText('Bob')
).toBeVisible({ timeout: 20_000 });
await expect(
bobRoom.channelsSidePanel.getByText('Alice')
).toBeVisible({ timeout: 20_000 });
await waitForPeerConnected(alice.page, 30_000);
await waitForPeerConnected(bob.page, 30_000);
await waitForConnectedPeerCount(alice.page, 1, 30_000);
await waitForConnectedPeerCount(bob.page, 1, 30_000);
await waitForAudioStatsPresent(alice.page, 30_000);
await waitForAudioStatsPresent(bob.page, 30_000);
await alice.page.waitForTimeout(3_000);
}
// Final assertion - must succeed after the (optional) rejoin.
try {
await Promise.all([waitForAllPeerAudioFlow(alice.page, 1, 60_000), waitForAllPeerAudioFlow(bob.page, 1, 60_000)]);
} catch (error) {
console.log('[Alice RTC Diagnostics]\n' + await dumpRtcDiagnostics(alice.page));
console.log('[Bob RTC Diagnostics]\n' + await dumpRtcDiagnostics(bob.page));
throw error;
}
});
await test.step('Bob still has TURN entry in localStorage', async () => {
const stored: StoredIceServerEntry[] = await bob.page.evaluate(
(key) => JSON.parse(localStorage.getItem(key) || '[]') as StoredIceServerEntry[],
ICE_STORAGE_KEY
);
const hasTurn = stored.some(
(entry) => entry.type === 'turn' && entry.urls === 'turn:localhost:3478'
);
expect(hasTurn).toBe(true);
});
});
});

View File

@@ -0,0 +1,803 @@
import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
import { startTestServer } from '../../helpers/test-server';
import {
dumpRtcDiagnostics,
getConnectedPeerCount,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
// ── Signal endpoint identifiers ──────────────────────────────────────
const PRIMARY_SIGNAL_ID = 'e2e-mixed-signal-a';
const SECONDARY_SIGNAL_ID = 'e2e-mixed-signal-b';
// ── Room / channel names ─────────────────────────────────────────────
const VOICE_ROOM_NAME = `Mixed Signal Voice ${Date.now()}`;
const SECONDARY_ROOM_NAME = `Mixed Signal Chat ${Date.now()}`;
const VOICE_CHANNEL = 'General';
// ── User constants ───────────────────────────────────────────────────
const USER_PASSWORD = 'TestPass123!';
const USER_COUNT = 8;
const EXPECTED_REMOTE_PEERS = USER_COUNT - 1;
const STABILITY_WINDOW_MS = 20_000;
// ── User signal configuration groups ─────────────────────────────────
//
// Group A (users 0-1): Both signal servers in network config (normal)
// Group B (users 2-3): Only primary signal - secondary NOT in config.
// They join the secondary room via invite link,
// which auto-adds the endpoint.
// Group C (users 4-5): Both signals initially, but secondary is removed
// after registration. They still see the room from
// search because the primary signal can discover it
// via findServerAcrossActiveEndpoints fallback.
// Group D (users 6-7): Only secondary signal in config. They join the
// primary room via invite link.
type SignalGroup = 'both' | 'primary-only' | 'both-then-remove-secondary' | 'secondary-only';
interface TestUser {
username: string;
displayName: string;
password: string;
group: SignalGroup;
}
type TestClient = Client & { user: TestUser };
function endpointsForGroup(
group: SignalGroup,
primaryUrl: string,
secondaryUrl: string
): SeededEndpointInput[] {
switch (group) {
case 'both':
return [
{
id: PRIMARY_SIGNAL_ID,
name: 'E2E Signal A',
url: primaryUrl,
isActive: true,
status: 'online'
},
{
id: SECONDARY_SIGNAL_ID,
name: 'E2E Signal B',
url: secondaryUrl,
isActive: true,
status: 'online'
}
];
case 'primary-only':
return [{ id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' }];
case 'both-then-remove-secondary':
// Seed both initially; test will remove secondary after registration.
return [
{
id: PRIMARY_SIGNAL_ID,
name: 'E2E Signal A',
url: primaryUrl,
isActive: true,
status: 'online'
},
{
id: SECONDARY_SIGNAL_ID,
name: 'E2E Signal B',
url: secondaryUrl,
isActive: true,
status: 'online'
}
];
case 'secondary-only':
return [{ id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' }];
}
}
test.describe('Mixed signal-config voice', () => {
test('8 users with different signal configs can voice, mute, deafen, and chat concurrently', async ({
createClient,
testServer
}) => {
test.setTimeout(720_000);
const secondaryServer = await startTestServer();
try {
const users = buildUsers();
const clients: TestClient[] = [];
// ── Create clients with per-group endpoint configs ───────────
for (const user of users) {
const client = await createClient();
const groupEndpoints = endpointsForGroup(user.group, testServer.url, secondaryServer.url);
await installTestServerEndpoints(client.context, groupEndpoints);
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
clients.push({ ...client, user });
}
// ── Register ─────────────────────────────────────────────────
await test.step('Register each user on their configured signal endpoint', async () => {
for (const client of clients) {
const registerPage = new RegisterPage(client.page);
const registrationEndpointId =
client.user.group === 'secondary-only' ? SECONDARY_SIGNAL_ID : PRIMARY_SIGNAL_ID;
await registerPage.goto();
await registerPage.serverSelect.selectOption(registrationEndpointId);
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
}
});
// ── Create rooms ────────────────────────────────────────────
await test.step('Create voice room on primary and chat room on secondary', async () => {
// Use a "both" user (client 0) to create both rooms
const searchPage = new ServerSearchPage(clients[0].page);
await searchPage.createServer(VOICE_ROOM_NAME, {
description: 'Voice room on primary signal',
sourceId: PRIMARY_SIGNAL_ID
});
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await searchPage.createServer(SECONDARY_ROOM_NAME, {
description: 'Chat room on secondary signal',
sourceId: SECONDARY_SIGNAL_ID
});
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
});
// ── Create invite links ─────────────────────────────────────
//
// Group B (primary-only) needs invite to secondary room.
// Group D (secondary-only) needs invite to primary room.
let primaryRoomInviteUrl: string;
let secondaryRoomInviteUrl: string;
await test.step('Create invite links for cross-signal rooms', async () => {
// Navigate to voice room to get its ID
await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
const primaryRoomId = await getCurrentRoomId(clients[0].page);
const userId = await getCurrentUserId(clients[0].page);
// Navigate to secondary room to get its ID
await openSavedRoomByName(clients[0].page, SECONDARY_ROOM_NAME);
const secondaryRoomId = await getCurrentRoomId(clients[0].page);
// Create invite for primary room (voice) via API
const primaryInvite = await createInviteViaApi(
testServer.url,
primaryRoomId,
userId,
clients[0].user.displayName
);
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
// Create invite for secondary room (chat) via API
const secondaryInvite = await createInviteViaApi(
secondaryServer.url,
secondaryRoomId,
userId,
clients[0].user.displayName
);
secondaryRoomInviteUrl = `/invite/${secondaryInvite.id}?server=${encodeURIComponent(secondaryServer.url)}`;
});
// ── Remove secondary endpoint for group C ───────────────────
await test.step('Remove secondary signal from group C users', async () => {
for (const client of clients.filter((clientItem) => clientItem.user.group === 'both-then-remove-secondary')) {
await client.page.evaluate((primaryEndpoint) => {
localStorage.setItem('metoyou_server_endpoints', JSON.stringify([primaryEndpoint]));
}, { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: testServer.url, isActive: true, isDefault: false, status: 'online' });
}
});
// ── Join rooms ──────────────────────────────────────────────
await test.step('All users join the voice room (some via search, some via invite)', async () => {
for (const client of clients.slice(1)) {
if (client.user.group === 'secondary-only') {
// Group D: no primary signal -> join voice room via invite
await client.page.goto(primaryRoomInviteUrl);
await waitForInviteJoin(client.page);
} else {
// Groups A, B, C: have primary signal -> join via search
await joinRoomFromSearch(client.page, VOICE_ROOM_NAME);
}
}
// Navigate client 0 back to voice room
await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
});
await test.step('All users also join the secondary chat room', async () => {
for (const client of clients.slice(1)) {
if (client.user.group === 'primary-only') {
// Group B: no secondary signal -> join chat room via invite
await client.page.goto(secondaryRoomInviteUrl);
await waitForInviteJoin(client.page);
} else if (client.user.group === 'secondary-only') {
// Group D: has secondary -> join via search
await openSearchView(client.page);
await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
} else {
// Groups A, C: can search
await openSearchView(client.page);
await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
}
}
// Ensure everyone navigates back to voice room
for (const client of clients) {
await openSavedRoomByName(client.page, VOICE_ROOM_NAME);
}
});
// ── Voice channel ───────────────────────────────────────────
await test.step('Create voice channel and join all 8 users', async () => {
const hostRoom = new ChatRoomPage(clients[0].page);
await hostRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
for (const client of clients) {
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
}
for (const client of clients) {
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
}
});
// ── Audio mesh ──────────────────────────────────────────────
await test.step('All users discover peers and audio flows pairwise', async () => {
await Promise.all(clients.map((client) =>
waitForPeerConnected(client.page, 45_000)
));
await Promise.all(clients.map((client) =>
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
));
await Promise.all(clients.map((client) =>
waitForAudioStatsPresent(client.page, 30_000)
));
await clients[0].page.waitForTimeout(5_000);
await Promise.all(clients.map((client) =>
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
));
});
// ── Voice workspace roster ──────────────────────────────────
await test.step('Voice workspace shows all 8 users on every client', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await openVoiceWorkspace(client.page);
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
}
});
// ── Stability + concurrent chat ─────────────────────────────
await test.step('Voice stays stable 20s while some users navigate and chat on other servers', async () => {
// Pick 2 users from different groups to navigate away and chat
const chatters = [clients[2], clients[6]]; // group C + group D
const stayers = clients.filter((clientItem) => !chatters.includes(clientItem));
// Chatters navigate to secondary room and send messages
for (const chatter of chatters) {
await openSavedRoomByName(chatter.page, SECONDARY_ROOM_NAME);
await expect(chatter.page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 10_000 });
}
const chatPage0 = new ChatMessagesPage(chatters[0].page);
const chatPage1 = new ChatMessagesPage(chatters[1].page);
await chatPage0.sendMessage(`Hello from ${chatters[0].user.displayName} while in voice!`);
await chatPage1.sendMessage(`Reply from ${chatters[1].user.displayName} also in voice!`);
// Verify messages arrive
await expect(
chatPage0.getMessageItemByText(`Reply from ${chatters[1].user.displayName}`)
).toBeVisible({ timeout: 15_000 });
await expect(
chatPage1.getMessageItemByText(`Hello from ${chatters[0].user.displayName}`)
).toBeVisible({ timeout: 15_000 });
// Meanwhile stability loop on all clients (including chatters - voice still active)
const deadline = Date.now() + STABILITY_WINDOW_MS;
while (Date.now() < deadline) {
for (const client of stayers) {
await expect.poll(async () => await getConnectedPeerCount(client.page), {
timeout: 10_000,
intervals: [500, 1_000]
}).toBe(EXPECTED_REMOTE_PEERS);
}
// Check chatters still have voice peers even while viewing another room
for (const chatter of chatters) {
await expect.poll(async () => await getConnectedPeerCount(chatter.page), {
timeout: 10_000,
intervals: [500, 1_000]
}).toBe(EXPECTED_REMOTE_PEERS);
}
if (Date.now() < deadline) {
await clients[0].page.waitForTimeout(5_000);
}
}
// Navigate chatters back to voice room
for (const chatter of chatters) {
await openSavedRoomByName(chatter.page, VOICE_ROOM_NAME);
}
// Verify audio still flowing after stability window
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
} catch (error) {
console.log(`[${client.user.displayName} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
// ── Mute ────────────────────────────────────────────────────
await test.step('Mute state propagates for every user across all clients', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.muteButton.click();
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: true,
isDeafened: false
});
await room.muteButton.click();
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: false,
isDeafened: false
});
}
});
await test.step('Audio still flows on all peers after mute cycling', async () => {
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
} catch (error) {
console.log(`[${client.user.displayName} post-mute RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
// ── Deafen ──────────────────────────────────────────────────
await test.step('Deafen state propagates for every user across all clients', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.deafenButton.click();
await client.page.waitForTimeout(500);
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: true,
isDeafened: true
});
await room.deafenButton.click();
await client.page.waitForTimeout(500);
// Un-deafen does NOT restore mute - user stays muted
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: true,
isDeafened: false
});
}
});
await test.step('Unmute all users and verify audio flows end-to-end', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.muteButton.click();
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: false,
isDeafened: false
});
}
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
} catch (error) {
console.log(`[${client.user.displayName} final RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
} finally {
await secondaryServer.stop();
}
});
});
// ── User builders ────────────────────────────────────────────────────
function buildUsers(): TestUser[] {
const groups: SignalGroup[] = [
'both',
'both', // 0-1
'primary-only',
'primary-only', // 2-3
'both-then-remove-secondary',
'both-then-remove-secondary', // 4-5
'secondary-only',
'secondary-only' // 6-7
];
return groups.map((group, index) => ({
username: `mixed_sig_${Date.now()}_${index + 1}`,
displayName: `Mixed User ${index + 1}`,
password: USER_PASSWORD,
group
}));
}
// ── API helpers ──────────────────────────────────────────────────────
async function createInviteViaApi(
serverBaseUrl: string,
roomId: string,
userId: string,
displayName: string
): Promise<{ id: string }> {
const response = await fetch(`${serverBaseUrl}/api/servers/${roomId}/invites`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requesterUserId: userId,
requesterDisplayName: displayName
})
});
if (!response.ok) {
throw new Error(`Failed to create invite: ${response.status} ${await response.text()}`);
}
return await response.json() as { id: string };
}
async function getCurrentRoomId(page: Page): Promise<string> {
return await page.evaluate(() => {
interface RoomShape { id: string }
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) {
throw new Error('Angular debug API unavailable');
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.();
if (!currentRoom?.id) {
throw new Error('No current room');
}
return currentRoom.id;
});
}
async function getCurrentUserId(page: Page): Promise<string> {
return await page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
interface UserShape {
id: string;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
throw new Error('Angular debug API unavailable');
}
const component = debugApi.getComponent(host);
const user = (component['currentUser'] as (() => UserShape | null) | undefined)?.();
if (!user?.id) {
throw new Error('Current user not found');
}
return user.id;
});
}
// ── Navigation helpers ───────────────────────────────────────────────
async function installDeterministicVoiceSettings(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('metoyou_voice_settings', JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
}));
});
}
async function openSearchView(page: Page): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers and users...');
if (await searchInput.isVisible().catch(() => false)) {
return;
}
await page.locator('button[title="Create Server"]').click();
await expect(searchInput).toBeVisible({ timeout: 20_000 });
}
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers and users...');
await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName);
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
await expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.dblclick();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
const roomButton = page.locator(`button[title="${roomName}"]`);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
async function waitForInviteJoin(page: Page): Promise<void> {
// Invite page loads -> auto-joins -> redirects to room
await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
}
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(expectedRoomName) => {
interface RoomShape { name?: string }
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 false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
return currentRoom?.name === expectedRoomName;
},
roomName,
{ timeout }
);
}
async function openVoiceWorkspace(page: Page): Promise<void> {
if (await page.locator('app-voice-workspace').isVisible()
.catch(() => false)) {
return;
}
const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i })
.first();
await expect(viewButton).toBeVisible({ timeout: 10_000 });
await viewButton.click();
}
// ── Voice helpers ────────────────────────────────────────────────────
async function joinVoiceChannelUntilConnected(page: Page, channelName: string, attempts = 3): Promise<void> {
const room = new ChatRoomPage(page);
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
await room.joinVoiceChannel(channelName);
try {
await waitForLocalVoiceChannelConnection(page, channelName, 20_000);
await expect(room.muteButton).toBeVisible({ timeout: 10_000 });
return;
} catch (error) {
lastError = error;
await page.waitForTimeout(1_000);
}
}
const lastErrorMessage = lastError instanceof Error
? `Last error: ${lastError.message}`
: 'Last error: unavailable';
throw new Error(`Failed to connect ${page.url()} to voice channel ${channelName}.\n${lastErrorMessage}`);
}
async function waitForLocalVoiceChannelConnection(page: Page, channelName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(name) => {
interface VoiceStateShape { isConnected?: boolean; roomId?: string; serverId?: string }
interface UserShape { voiceState?: VoiceStateShape }
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
interface RoomShape { id: string; channels?: ChannelShape[] }
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 false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name);
const voiceState = currentUser?.voiceState;
return !!voiceChannel
&& voiceState?.isConnected === true
&& voiceState.roomId === voiceChannel.id
&& voiceState.serverId === currentRoom.id;
},
channelName,
{ timeout }
);
}
// ── Roster / state helpers ───────────────────────────────────────────
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
await page.waitForFunction(
(count) => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-voice-workspace');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
return connectedUsers.length === count;
},
expectedCount,
{ timeout: 45_000 }
);
}
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
await page.waitForFunction(
({ expected, name }) => {
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
interface RoomShape { channels?: ChannelShape[] }
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 false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const channelId = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name)?.id;
if (!channelId) {
return false;
}
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
return roster.length === expected;
},
{ expected: expectedCount, name: channelName },
{ timeout: 30_000 }
);
}
async function waitForVoiceStateAcrossPages(
clients: readonly TestClient[],
displayName: string,
expectedState: { isMuted: boolean; isDeafened: boolean }
): Promise<void> {
for (const client of clients) {
await client.page.waitForFunction(
({ expectedDisplayName, expectedMuted, expectedDeafened }) => {
interface VoiceStateShape { isMuted?: boolean; isDeafened?: boolean }
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
interface UserShape { displayName: string; voiceState?: VoiceStateShape }
interface RoomShape { channels?: ChannelShape[] }
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 false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((ch) => ch.type === 'voice');
if (!voiceChannel) {
return false;
}
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
const entry = roster.find((userEntry) => userEntry.displayName === expectedDisplayName);
return entry?.voiceState?.isMuted === expectedMuted
&& entry?.voiceState?.isDeafened === expectedDeafened;
},
{
expectedDisplayName: displayName,
expectedMuted: expectedState.isMuted,
expectedDeafened: expectedState.isDeafened
},
{ timeout: 30_000 }
);
}
}

View File

@@ -0,0 +1,763 @@
import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
import { startTestServer } from '../../helpers/test-server';
import {
dumpRtcDiagnostics,
getConnectedPeerCount,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
const PRIMARY_ROOM_NAME = `Dual Signal Voice A ${Date.now()}`;
const SECONDARY_ROOM_NAME = `Dual Signal Voice B ${Date.now()}`;
const VOICE_CHANNEL = 'General';
const USER_PASSWORD = 'TestPass123!';
const USER_COUNT = 8;
const EXPECTED_REMOTE_PEERS = USER_COUNT - 1;
const STABILITY_WINDOW_MS = 20_000;
interface TestUser {
username: string;
displayName: string;
password: string;
}
type TestClient = Client & {
user: TestUser;
};
test.describe('Dual-signal multi-user voice', () => {
test('keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds', async ({
createClient,
testServer
}) => {
test.setTimeout(720_000);
const secondaryServer = await startTestServer();
try {
const endpoints: SeededEndpointInput[] = [
{
id: PRIMARY_SIGNAL_ID,
name: 'E2E Signal A',
url: testServer.url,
isActive: true,
status: 'online'
},
{
id: SECONDARY_SIGNAL_ID,
name: 'E2E Signal B',
url: secondaryServer.url,
isActive: true,
status: 'online'
}
];
const users = buildUsers();
const clients = await createTrackedClients(createClient, users, endpoints);
await test.step('Register every user with both active endpoints available', async () => {
for (const client of clients) {
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.serverSelect.selectOption(PRIMARY_SIGNAL_ID);
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
}
});
await test.step('Create primary and secondary rooms on different signal endpoints', async () => {
const searchPage = new ServerSearchPage(clients[0].page);
await searchPage.createServer(PRIMARY_ROOM_NAME, {
description: 'Primary signal room for 8-user voice mesh',
sourceId: PRIMARY_SIGNAL_ID
});
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await searchPage.createServer(SECONDARY_ROOM_NAME, {
description: 'Secondary signal room for dual-socket coverage',
sourceId: SECONDARY_SIGNAL_ID
});
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
});
await test.step('Every user joins both rooms to keep 2 signal sockets open', async () => {
for (const client of clients.slice(1)) {
await joinRoomFromSearch(client.page, PRIMARY_ROOM_NAME);
}
for (const client of clients.slice(1)) {
await openSearchView(client.page);
await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
}
for (const client of clients) {
await openSavedRoomByName(client.page, PRIMARY_ROOM_NAME);
await waitForConnectedSignalManagerCount(client.page, 2);
}
});
await test.step('Create voice channel and join all 8 users', async () => {
const hostRoom = new ChatRoomPage(clients[0].page);
await hostRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
for (const client of clients) {
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
}
for (const client of clients) {
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
}
});
await test.step('All users discover all peers and audio flows pairwise', async () => {
// Wait for all clients to have at least one connected peer (fast)
await Promise.all(clients.map((client) =>
waitForPeerConnected(client.page, 45_000)
));
// Wait for all clients to have all 7 peers connected
await Promise.all(clients.map((client) =>
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
));
// Wait for audio stats to appear on all clients
await Promise.all(clients.map((client) =>
waitForAudioStatsPresent(client.page, 30_000)
));
// Allow the mesh to settle - voice routing, allowed-peer-id
// propagation and renegotiation all need time after the last
// user joins.
await clients[0].page.waitForTimeout(5_000);
// Check bidirectional audio flow on each client
await Promise.all(clients.map((client) =>
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
));
});
await test.step('Voice workspace and side panel show all 8 users on every client', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await openVoiceWorkspace(client.page);
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
await waitForConnectedSignalManagerCount(client.page, 2);
}
});
await test.step('Voice stays stable for more than 20 seconds across both signals', async () => {
const deadline = Date.now() + STABILITY_WINDOW_MS;
while (Date.now() < deadline) {
for (const client of clients) {
await expect.poll(async () => await getConnectedPeerCount(client.page), {
timeout: 10_000,
intervals: [500, 1_000]
}).toBe(EXPECTED_REMOTE_PEERS);
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
timeout: 10_000,
intervals: [500, 1_000]
}).toBe(2);
}
if (Date.now() < deadline) {
await clients[0].page.waitForTimeout(5_000);
}
}
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
} catch (error) {
console.log(`[${client.user.displayName} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
await test.step('Mute state propagates for every user across all clients', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.muteButton.click();
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: true,
isDeafened: false
});
await room.muteButton.click();
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: false,
isDeafened: false
});
}
});
await test.step('Audio still flows on all peers after mute cycling', async () => {
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
} catch (error) {
console.log(`[${client.user.displayName} post-mute RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
await test.step('Deafen state propagates for every user across all clients', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.deafenButton.click();
await client.page.waitForTimeout(500);
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: true,
isDeafened: true
});
await room.deafenButton.click();
await client.page.waitForTimeout(500);
// Un-deafen does NOT restore mute - the user stays muted
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: true,
isDeafened: false
});
}
});
await test.step('Unmute all users and verify audio flows end-to-end', async () => {
// Every user is left muted after deafen cycling - unmute them all
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.muteButton.click();
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: false,
isDeafened: false
});
}
// Final audio flow check on every peer - confirms the full
// send/receive pipeline still works after mute+deafen cycling
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
} catch (error) {
console.log(`[${client.user.displayName} final RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
} finally {
await secondaryServer.stop();
}
});
});
function buildUsers(): TestUser[] {
return Array.from({ length: USER_COUNT }, (_value, index) => ({
username: `voice8_user_${Date.now()}_${index + 1}`,
displayName: `Voice User ${index + 1}`,
password: USER_PASSWORD
}));
}
async function createTrackedClients(
createClient: () => Promise<Client>,
users: TestUser[],
endpoints: readonly SeededEndpointInput[]
): Promise<TestClient[]> {
const clients: TestClient[] = [];
for (const user of users) {
const client = await createClient();
await installTestServerEndpoints(client.context, endpoints);
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
clients.push({
...client,
user
});
}
return clients;
}
async function installDeterministicVoiceSettings(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('metoyou_voice_settings', JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
}));
});
}
async function openSearchView(page: Page): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers and users...');
if (await searchInput.isVisible().catch(() => false)) {
return;
}
await page.locator('button[title="Create Server"]').click();
await expect(searchInput).toBeVisible({ timeout: 20_000 });
}
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers and users...');
await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName);
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
await expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.dblclick();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
const roomButton = page.locator(`button[title="${roomName}"]`);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(expectedRoomName) => {
interface RoomShape {
name?: string;
}
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 false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
return currentRoom?.name === expectedRoomName;
},
roomName,
{ timeout }
);
}
async function openVoiceWorkspace(page: Page): Promise<void> {
const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i })
.first();
if (await page.locator('app-voice-workspace').isVisible()
.catch(() => false)) {
return;
}
await expect(viewButton).toBeVisible({ timeout: 10_000 });
await viewButton.click();
}
async function joinVoiceChannelUntilConnected(page: Page, channelName: string, attempts = 3): Promise<void> {
const room = new ChatRoomPage(page);
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
await room.joinVoiceChannel(channelName);
try {
await waitForLocalVoiceChannelConnection(page, channelName, 20_000);
await expect(room.muteButton).toBeVisible({ timeout: 10_000 });
return;
} catch (error) {
lastError = error;
await page.waitForTimeout(1_000);
}
}
const diagnostics = await getVoiceJoinDiagnostics(page, channelName);
const displayName = diagnostics.currentUser?.displayName ?? 'Unknown user';
throw new Error([
`Failed to connect ${displayName} to voice channel ${channelName}.`,
lastError instanceof Error ? `Last error: ${lastError.message}` : 'Last error: unavailable',
`Current room: ${diagnostics.currentRoom?.name ?? 'none'} (${diagnostics.currentRoom?.id ?? 'n/a'})`,
`Current user id: ${diagnostics.currentUser?.id ?? 'none'} / ${diagnostics.currentUser?.oderId ?? 'none'}`,
`Current user voice state: ${JSON.stringify(diagnostics.currentUser?.voiceState ?? null)}`,
`Voice channel id: ${diagnostics.voiceChannel?.id ?? 'missing'}`,
`Visible voice roster: ${diagnostics.voiceUsers.join(', ') || 'none'}`,
`Connected signaling managers: ${diagnostics.connectedSignalCount}`,
`Local voice facade state: ${JSON.stringify(diagnostics.localVoiceState)}`,
`Voice connection error: ${diagnostics.connectionErrorMessage ?? 'none'}`
].join('\n'));
}
async function waitForLocalVoiceChannelConnection(page: Page, channelName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(name) => {
interface VoiceStateShape {
isConnected?: boolean;
roomId?: string;
serverId?: string;
}
interface UserShape {
voiceState?: VoiceStateShape;
}
interface ChannelShape {
id: string;
name: string;
type: 'text' | 'voice';
}
interface RoomShape {
id: string;
channels?: ChannelShape[];
}
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 false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name);
const voiceState = currentUser?.voiceState;
return !!voiceChannel
&& voiceState?.isConnected === true
&& voiceState.roomId === voiceChannel.id
&& voiceState.serverId === currentRoom.id;
},
channelName,
{ timeout }
);
}
async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise<{
connectedSignalCount: number;
connectionErrorMessage: string | null;
currentRoom: { id?: string; name?: string } | null;
currentUser: { id?: string; oderId?: string; displayName?: string; voiceState?: Record<string, unknown> } | null;
localVoiceState: {
isVoiceConnected: boolean;
localStreamTracks: number;
rawMicTracks: number;
};
voiceChannel: { id?: string; name?: string } | null;
voiceUsers: string[];
}> {
return await page.evaluate((name) => {
interface VoiceStateShape {
isConnected?: boolean;
isMuted?: boolean;
isDeafened?: boolean;
roomId?: string;
serverId?: string;
}
interface UserShape {
id?: string;
oderId?: string;
displayName?: string;
voiceState?: VoiceStateShape;
}
interface ChannelShape {
id: string;
name: string;
type: 'text' | 'voice';
}
interface RoomShape {
id?: string;
name?: string;
channels?: ChannelShape[];
}
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 {
connectedSignalCount: 0,
connectionErrorMessage: 'Angular debug API unavailable',
currentRoom: null,
currentUser: null,
localVoiceState: {
isVoiceConnected: false,
localStreamTracks: 0,
rawMicTracks: 0
},
voiceChannel: null,
voiceUsers: []
};
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name) ?? null;
const voiceUsers = voiceChannel
? ((component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [])
.map((user) => user.displayName ?? 'Unknown user')
: [];
const voiceConnection = component['voiceConnection'] as {
getLocalStream?: () => MediaStream | null;
getRawMicStream?: () => MediaStream | null;
isVoiceConnected?: () => boolean;
} | undefined;
const realtime = component['realtime'] as {
connectionErrorMessage?: () => string | null;
signalingTransportHandler?: {
getConnectedSignalingManagers?: () => { signalUrl: string }[];
};
} | undefined;
return {
connectedSignalCount: realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0,
connectionErrorMessage: realtime?.connectionErrorMessage?.() ?? null,
currentRoom,
currentUser,
localVoiceState: {
isVoiceConnected: voiceConnection?.isVoiceConnected?.() ?? false,
localStreamTracks: voiceConnection?.getLocalStream?.()?.getTracks().length ?? 0,
rawMicTracks: voiceConnection?.getRawMicStream?.()?.getTracks().length ?? 0
},
voiceChannel,
voiceUsers
};
}, channelName);
}
async function waitForConnectedSignalManagerCount(page: Page, expectedCount: number): Promise<void> {
await page.waitForFunction(
(count) => {
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 false;
}
const component = debugApi.getComponent(host);
const realtime = component['realtime'] as {
signalingTransportHandler?: {
getConnectedSignalingManagers?: () => { signalUrl: string }[];
};
} | undefined;
const countValue = realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
return countValue === count;
},
expectedCount,
{ timeout: 30_000 }
);
}
async function getConnectedSignalManagerCount(page: Page): Promise<number> {
return await page.evaluate(() => {
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 0;
}
const component = debugApi.getComponent(host);
const realtime = component['realtime'] as {
signalingTransportHandler?: {
getConnectedSignalingManagers?: () => { signalUrl: string }[];
};
} | undefined;
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
});
}
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
await page.waitForFunction(
(count) => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-voice-workspace');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
return connectedUsers.length === count;
},
expectedCount,
{ timeout: 45_000 }
);
}
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
await page.waitForFunction(
({ expected, name }) => {
interface ChannelShape {
id: string;
name: string;
type: 'text' | 'voice';
}
interface RoomShape {
channels?: ChannelShape[];
}
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 false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const channelId = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name)?.id;
if (!channelId) {
return false;
}
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
return roster.length === expected;
},
{ expected: expectedCount, name: channelName },
{ timeout: 30_000 }
);
}
async function waitForVoiceStateAcrossPages(
clients: readonly TestClient[],
displayName: string,
expectedState: { isMuted: boolean; isDeafened: boolean }
): Promise<void> {
for (const client of clients) {
await client.page.waitForFunction(
({ expectedDisplayName, expectedMuted, expectedDeafened }) => {
interface VoiceStateShape {
isMuted?: boolean;
isDeafened?: boolean;
}
interface ChannelShape {
id: string;
name: string;
type: 'text' | 'voice';
}
interface UserShape {
displayName: string;
voiceState?: VoiceStateShape;
}
interface RoomShape {
channels?: ChannelShape[];
}
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 false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice');
if (!voiceChannel) {
return false;
}
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
const entry = roster.find((user) => user.displayName === expectedDisplayName);
return entry?.voiceState?.isMuted === expectedMuted
&& entry?.voiceState?.isDeafened === expectedDeafened;
},
{
expectedDisplayName: displayName,
expectedMuted: expectedState.isMuted,
expectedDeafened: expectedState.isDeafened
},
{ timeout: 30_000 }
);
}
}

View File

@@ -0,0 +1,298 @@
import { test, expect } from '../../fixtures/multi-client';
import {
installWebRTCTracking,
installAutoResumeAudioContext,
waitForPeerConnected,
isPeerStillConnected,
getAudioStatsDelta,
waitForAudioFlow,
waitForAudioStatsPresent,
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';
/**
* Full user journey: register -> create server -> join -> voice -> verify audio
* for 10+ seconds of stable connectivity.
*
* Uses two independent browser contexts (Alice & Bob) to simulate real
* multi-user WebRTC voice chat.
*/
const ALICE = { username: `alice_${Date.now()}`, displayName: 'Alice', password: 'TestPass123!' };
const BOB = { username: `bob_${Date.now()}`, displayName: 'Bob', password: 'TestPass123!' };
const SERVER_NAME = `E2E Test Server ${Date.now()}`;
const VOICE_CHANNEL = 'General';
test.describe('Full user journey: register -> server -> voice chat', () => {
test('two users register, create server, join voice, and stay connected 10+ seconds with audio', async ({ createClient }) => {
test.setTimeout(180_000); // 3 min - covers registration, server creation, voice establishment, and 10s stability check
const alice = await createClient();
const bob = await createClient();
// Install WebRTC tracking before any navigation
await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page);
await installAutoResumeAudioContext(alice.page);
await installAutoResumeAudioContext(bob.page);
// Seed deterministic voice settings so noise reduction doesn't
// swallow the fake audio tone.
const voiceSettings = JSON.stringify({
inputVolume: 100, outputVolume: 100, audioBitrate: 96,
latencyProfile: 'balanced', includeSystemAudio: false,
noiseReduction: false, screenShareQuality: 'balanced',
askScreenShareQuality: false
});
await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
// Forward browser console for debugging
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
// ── Step 1: Register both users ──────────────────────────────────
await test.step('Alice registers an account', async () => {
const registerPage = new RegisterPage(alice.page);
await registerPage.goto();
await expect(registerPage.submitButton).toBeVisible();
await registerPage.register(ALICE.username, ALICE.displayName, ALICE.password);
// After registration, app should navigate to /search
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
});
await test.step('Bob registers an account', async () => {
const registerPage = new RegisterPage(bob.page);
await registerPage.goto();
await expect(registerPage.submitButton).toBeVisible();
await registerPage.register(BOB.username, BOB.displayName, BOB.password);
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
});
// ── Step 2: Alice creates a server ───────────────────────────────
await test.step('Alice creates a new server', async () => {
const searchPage = new ServerSearchPage(alice.page);
await searchPage.createServer(SERVER_NAME, {
description: 'E2E test server for voice testing'
});
// After server creation, app navigates to the room
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
// ── Step 3: Bob joins the server ─────────────────────────────────
await test.step('Bob finds and joins the server', async () => {
const searchPage = new ServerSearchPage(bob.page);
await searchPage.joinServerFromSearch(SERVER_NAME);
// Bob should be in the room now
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
// ── Step 4: Create a voice channel (if one doesn't exist) ────────
await test.step('Alice ensures a voice channel is available', async () => {
const chatRoom = new ChatRoomPage(alice.page);
const existingVoiceChannel = alice.page.locator('app-rooms-side-panel')
.getByRole('button', { name: VOICE_CHANNEL, exact: true });
const voiceChannelExists = await existingVoiceChannel.count() > 0;
if (!voiceChannelExists) {
// Click "Create Voice Channel" plus button
await chatRoom.openCreateVoiceChannelDialog();
await chatRoom.createChannel(VOICE_CHANNEL);
// Wait for the channel to appear
await expect(existingVoiceChannel).toBeVisible({ timeout: 10_000 });
}
});
// ── Step 5: Both users join the voice channel ────────────────────
await test.step('Alice joins the voice channel', async () => {
const chatRoom = new ChatRoomPage(alice.page);
await chatRoom.joinVoiceChannel(VOICE_CHANNEL);
// Voice controls should appear (indicates voice is connected)
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
});
await test.step('Bob joins the voice channel', async () => {
const chatRoom = new ChatRoomPage(bob.page);
await chatRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
});
// ── Step 6: Verify WebRTC connection establishes ─────────────────
await test.step('WebRTC peer connection reaches "connected" state', async () => {
await waitForPeerConnected(alice.page, 30_000);
await waitForPeerConnected(bob.page, 30_000);
// 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 ───────────
await test.step('Audio packets are flowing between Alice and Bob', async () => {
// Chromium's --use-fake-device-for-media-stream can produce a
// silent capture track on the very first getUserMedia call. If
// bidirectional audio doesn't flow within a short window, leave
// and rejoin voice to re-acquire the mic.
let aliceDelta = await waitForAudioFlow(alice.page, 15_000);
let bobDelta = await waitForAudioFlow(bob.page, 15_000);
const isFlowing = (delta: typeof aliceDelta) =>
(delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0) &&
(delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0);
if (!isFlowing(aliceDelta) || !isFlowing(bobDelta)) {
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
await aliceRoom.disconnectButton.click();
await bobRoom.disconnectButton.click();
await alice.page.waitForTimeout(2_000);
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
await waitForPeerConnected(alice.page, 30_000);
await waitForPeerConnected(bob.page, 30_000);
await waitForAudioStatsPresent(alice.page, 20_000);
await waitForAudioStatsPresent(bob.page, 20_000);
aliceDelta = await waitForAudioFlow(alice.page, 30_000);
bobDelta = await waitForAudioFlow(bob.page, 30_000);
}
if (aliceDelta.outboundBytesDelta === 0 || aliceDelta.inboundBytesDelta === 0
|| bobDelta.outboundBytesDelta === 0 || bobDelta.inboundBytesDelta === 0) {
console.log('[Alice RTC Diagnostics]\n' + await dumpRtcDiagnostics(alice.page));
console.log('[Bob RTC Diagnostics]\n' + await dumpRtcDiagnostics(bob.page));
}
expectAudioFlow(aliceDelta, 'Alice');
expectAudioFlow(bobDelta, 'Bob');
});
// ── Step 8: Verify UI states are correct ─────────────────────────
await test.step('Voice UI shows correct state for both users', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
// Both should see voice controls with "Connected" status
await expect(alice.page.locator('app-voice-controls')).toBeVisible();
await expect(bob.page.locator('app-voice-controls')).toBeVisible();
// Both should see the voice workspace or at least voice users listed
// Check that both users appear in the voice channel user list
const aliceSeesBob = aliceRoom.channelsSidePanel.getByText(BOB.displayName).first();
const bobSeesAlice = bobRoom.channelsSidePanel.getByText(ALICE.displayName).first();
await expect(aliceSeesBob).toBeVisible({ timeout: 10_000 });
await expect(bobSeesAlice).toBeVisible({ timeout: 10_000 });
});
// ── Step 9: Stay connected for 10+ seconds, verify stability ─────
await test.step('Connection remains stable for 10+ seconds', async () => {
// Check connectivity at 0s, 5s, and 10s intervals
for (const checkpoint of [
0,
5_000,
5_000
]) {
if (checkpoint > 0) {
await alice.page.waitForTimeout(checkpoint);
}
const aliceConnected = await isPeerStillConnected(alice.page);
const bobConnected = await isPeerStillConnected(bob.page);
expect(aliceConnected, 'Alice should still be connected').toBe(true);
expect(bobConnected, 'Bob should still be connected').toBe(true);
}
// After 10s total, verify audio is still flowing
const aliceDelta = await waitForAudioFlow(alice.page, 15_000);
const bobDelta = await waitForAudioFlow(bob.page, 15_000);
expectAudioFlow(aliceDelta, 'Alice after 10s');
expectAudioFlow(bobDelta, 'Bob after 10s');
});
// ── Step 10: Verify mute/unmute works correctly ──────────────────
await test.step('Mute toggle works correctly', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
// Alice mutes - click the first button in voice controls (mute button)
await aliceRoom.muteButton.click();
// After muting, Alice's outbound audio should stop increasing
// When muted, bytesSent may still show small comfort noise or zero growth
// The key assertion is that Bob's inbound for Alice's stream stops or reduces
await getAudioStatsDelta(alice.page, 2_000);
// Alice unmutes
await aliceRoom.muteButton.click();
// After unmuting, outbound should resume
const unmutedDelta = await waitForAudioFlow(alice.page, 15_000);
expectAudioFlow(unmutedDelta, 'Alice after unmuting');
});
// ── Step 11: Clean disconnect ────────────────────────────────────
await test.step('Alice disconnects from voice', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
// Click the disconnect/hang-up button
await aliceRoom.disconnectButton.click();
// Connected controls should collapse for Alice after disconnect
await expect(aliceRoom.disconnectButton).not.toBeVisible({ timeout: 10_000 });
});
});
});
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);
}

33
electron/README.md Normal file
View File

@@ -0,0 +1,33 @@
# Electron Shell
Electron main-process package for MetoYou / Toju. This directory owns desktop bootstrap, the preload bridge, IPC handlers, desktop persistence glue, updater integration, and window-level behavior.
## Commands
- `npm run build:electron` builds the Electron TypeScript output to `dist/electron`.
- `npm run electron` builds the product client and Electron, then launches the desktop app.
- `npm run electron:dev` starts the Angular client and Electron together.
- `npm run dev` starts the full desktop stack: server, Angular client, and Electron.
- `npm run electron:build`, `npm run electron:build:win`, `npm run electron:build:mac`, and `npm run electron:build:linux` create packaged desktop builds.
## Structure
| Path | Description |
| --- | --- |
| `main.ts` | Electron app bootstrap and process entry point |
| `preload.ts` | Typed renderer-facing preload bridge |
| `process-list.ts` | Linux/Windows process-name scan used by now-playing game detection |
| `app/` | App lifecycle and startup composition |
| `ipc/` | Renderer-invoked IPC handlers |
| `cqrs/` | Local database command/query handlers and mappings |
| `db/`, `entities/`, `migrations/` | Desktop persistence and schema evolution |
| `audio/` | Desktop audio integrations |
| `update/` | Desktop updater flow |
| `window/` | Window creation and window-level behavior |
## Notes
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
- Plugin client data is stored in the local Electron SQLite database in the dedicated user-scoped `plugin_data` table. Renderer plugins reach it through CQRS commands/queries exposed by the preload bridge; the signal server must not be used for arbitrary plugin data persistence.
- Treat `dist/electron/` and `dist-electron/` as generated output.
- See [AGENTS.md](AGENTS.md) for package-level editing rules.

View File

@@ -0,0 +1,69 @@
import { randomBytes } from 'crypto';
export interface IssuedToken {
token: string;
userId: string;
username: string;
displayName: string;
signalingServerUrl: string;
issuedAt: number;
expiresAt: number;
}
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
const tokens = new Map<string, IssuedToken>();
export function issueToken(params: {
userId: string;
username: string;
displayName: string;
signalingServerUrl: string;
}): IssuedToken {
const token = randomBytes(32).toString('hex');
const issuedAt = Date.now();
const issued: IssuedToken = {
token,
issuedAt,
expiresAt: issuedAt + TOKEN_TTL_MS,
userId: params.userId,
username: params.username,
displayName: params.displayName,
signalingServerUrl: params.signalingServerUrl
};
tokens.set(token, issued);
return issued;
}
export function consumeToken(token: string): IssuedToken | null {
const issued = tokens.get(token);
if (!issued) {
return null;
}
if (issued.expiresAt < Date.now()) {
tokens.delete(token);
return null;
}
return issued;
}
export function revokeToken(token: string): void {
tokens.delete(token);
}
export function clearAllTokens(): void {
tokens.clear();
}
export function pruneExpiredTokens(): void {
const now = Date.now();
for (const [token, issued] of tokens) {
if (issued.expiresAt < now) {
tokens.delete(token);
}
}
}

133
electron/api/docs-html.ts Normal file
View File

@@ -0,0 +1,133 @@
import { promises as fs } from 'fs';
import * as path from 'path';
function getScalarBundleCandidates(): string[] {
const processWithResources = process as NodeJS.Process & { resourcesPath?: string };
const candidates: string[] = [];
if (processWithResources.resourcesPath) {
candidates.push(path.join(processWithResources.resourcesPath, 'scalar', 'api-reference.js'));
}
candidates.push(path.join(process.cwd(), 'node_modules', '@scalar', 'api-reference', 'dist', 'browser', 'standalone.js'));
try {
candidates.push(path.join(path.dirname(require.resolve('@scalar/api-reference')), 'browser', 'standalone.js'));
} catch {
// ignore; the packaged app path above is the production path
}
return candidates;
}
export async function getScalarApiReferenceBundlePath(): Promise<string | null> {
for (const candidate of getScalarBundleCandidates()) {
try {
await fs.access(candidate);
return candidate;
} catch {
// try the next candidate
}
}
return null;
}
export function getDocsHtml(specUrl: string): string {
const scalarConfig = {
url: specUrl,
theme: 'default',
layout: 'modern',
proxyUrl: '',
telemetry: false,
persistAuth: false,
showDeveloperTools: 'never',
hideDownloadButton: false,
hideTestRequestButton: false,
hideClientButton: false,
externalUrls: {
dashboardUrl: '',
registryUrl: '',
proxyUrl: '',
apiBaseUrl: ''
},
agent: {
disabled: true,
hideAddApi: true
},
mcp: {
disabled: true
}
};
const contentSecurityPolicy = [
"default-src 'none'",
"script-src 'self' 'nonce-metoyou-local-api-docs'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self'",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'none'"
].join('; ');
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
http-equiv="Content-Security-Policy"
content="${contentSecurityPolicy}"
/>
<title>MetoYou Local API</title>
<style>
:root { color-scheme: light dark; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0b0d11;
color: #e6e9ee;
}
.placeholder {
max-width: 720px;
margin: 8vh auto;
padding: 2rem;
background: #14181f;
border: 1px solid #232a35;
border-radius: 12px;
}
h1 { margin-top: 0; }
a { color: #7aa2f7; }
code { background: #1f262f; padding: 0.1rem 0.4rem; border-radius: 4px; }
#api-reference { min-height: 100vh; }
</style>
</head>
<body>
<div id="api-reference"></div>
<noscript>
<div class="placeholder">
<h1>API Documentation</h1>
<p>JavaScript is required to render Scalar. The OpenAPI specification is available directly:</p>
<p><a href="${specUrl}">${specUrl}</a></p>
</div>
</noscript>
<script nonce="metoyou-local-api-docs" src="/scalar/api-reference.js"></script>
<script nonce="metoyou-local-api-docs">
(function () {
var config = ${JSON.stringify(scalarConfig)};
if (!window.Scalar || typeof window.Scalar.createApiReference !== 'function') {
var root = document.getElementById('api-reference');
root.innerHTML = '<div class="placeholder"><h1>API Documentation</h1>'
+ '<p>The bundled Scalar UI could not be loaded.</p>'
+ '<p>Spec: <a href="' + config.url + '">' + config.url + '</a></p></div>';
return;
}
window.Scalar.createApiReference('#api-reference', config);
})();
</script>
</body>
</html>`;
}

View File

@@ -0,0 +1,108 @@
import { promises as fs } from 'fs';
import * as path from 'path';
import { HttpError } from './http-helpers';
const MIME_TYPES_BY_EXTENSION: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2'
};
function getDocsRootCandidates(): string[] {
const processWithResources = process as NodeJS.Process & { resourcesPath?: string };
const candidates: string[] = [];
if (processWithResources.resourcesPath) {
candidates.push(path.join(processWithResources.resourcesPath, 'docusaurus'));
}
candidates.push(path.join(process.cwd(), 'docs-site', 'build'));
return candidates;
}
async function getDocusaurusBuildRoot(): Promise<string | null> {
for (const candidate of getDocsRootCandidates()) {
try {
const stat = await fs.stat(candidate);
if (stat.isDirectory()) {
return candidate;
}
} catch {
// try next candidate
}
}
return null;
}
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
}
function resolveAssetPath(root: string, pathname: string): string {
const withoutPrefix = pathname.replace(/^\/docusaurus\/?/u, '');
const decoded = decodeURIComponent(withoutPrefix);
const normalized = decoded.endsWith('/') || decoded === '' ? path.join(decoded, 'index.html') : decoded;
const absolutePath = path.resolve(root, normalized);
if (!isPathInside(root, absolutePath)) {
throw new HttpError(400, 'Invalid Docusaurus asset path', 'INVALID_DOCS_PATH');
}
return absolutePath;
}
export async function resolveDocusaurusRoute(pathname: string): Promise<{ filePath: string; contentType: string }> {
const root = await getDocusaurusBuildRoot();
if (!root) {
throw new HttpError(
503,
'Docusaurus build is not available. Run npm run build:docs before opening the docs endpoint.',
'DOCUSAURUS_BUILD_MISSING'
);
}
let filePath = resolveAssetPath(root, pathname);
try {
const stat = await fs.stat(filePath);
if (stat.isDirectory()) {
filePath = path.join(filePath, 'index.html');
}
} catch {
const directoryIndexPath = path.join(filePath, 'index.html');
try {
await fs.access(directoryIndexPath);
filePath = directoryIndexPath;
} catch {
filePath = path.join(root, '404.html');
}
}
if (!isPathInside(root, filePath)) {
throw new HttpError(400, 'Invalid Docusaurus asset path', 'INVALID_DOCS_PATH');
}
const contentType = MIME_TYPES_BY_EXTENSION[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
return { filePath, contentType };
}

View File

@@ -0,0 +1,108 @@
import { IncomingMessage, ServerResponse } from 'http';
export interface RequestContext {
method: string;
url: URL;
pathname: string;
headers: IncomingMessage['headers'];
remoteAddress: string;
bearerToken: string | null;
}
const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB
export function getBearerToken(headers: IncomingMessage['headers']): string | null {
const raw = headers.authorization;
if (typeof raw !== 'string') {
return null;
}
const trimmed = raw.trim();
if (!/^bearer\s+/iu.test(trimmed)) {
return null;
}
const token = trimmed.replace(/^bearer\s+/iu, '').trim();
return token.length > 0 ? token : null;
}
export async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
const length = Number(req.headers['content-length'] ?? 0);
if (length > MAX_BODY_BYTES) {
throw new HttpError(413, 'Request body too large', 'BODY_TOO_LARGE');
}
const chunks: Buffer[] = [];
let received = 0;
for await (const chunk of req) {
const buffer = chunk instanceof Buffer ? chunk : Buffer.from(chunk as string);
received += buffer.length;
if (received > MAX_BODY_BYTES) {
throw new HttpError(413, 'Request body too large', 'BODY_TOO_LARGE');
}
chunks.push(buffer);
}
if (chunks.length === 0) {
return {} as T;
}
const raw = Buffer.concat(chunks).toString('utf8');
try {
return JSON.parse(raw) as T;
} catch {
throw new HttpError(400, 'Invalid JSON body', 'INVALID_JSON');
}
}
export function sendJson(res: ServerResponse, status: number, payload: unknown): void {
if (!res.headersSent) {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
}
res.end(JSON.stringify(payload));
}
export function sendText(res: ServerResponse, status: number, text: string, contentType = 'text/plain; charset=utf-8'): void {
if (!res.headersSent) {
res.statusCode = status;
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'no-store');
}
res.end(text);
}
export class HttpError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, message: string, code: string) {
super(message);
this.status = status;
this.code = code;
}
}
export function sendError(res: ServerResponse, error: unknown): void {
if (error instanceof HttpError) {
sendJson(res, error.status, { error: error.message, errorCode: error.code });
return;
}
const message = error instanceof Error ? error.message : 'Internal server error';
sendJson(res, 500, { error: message, errorCode: 'INTERNAL_ERROR' });
}

8
electron/api/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export {
applyLocalApiSettings,
getLocalApiSnapshot,
startLocalApiServer,
stopLocalApiServer,
type LocalApiSnapshot,
type LocalApiStatus
} from './local-api-server';

View File

@@ -0,0 +1,286 @@
import {
createServer,
IncomingMessage,
Server,
ServerResponse
} from 'http';
import { createReadStream } from 'fs';
import { AddressInfo } from 'net';
import { pipeline } from 'stream/promises';
import { getDataSource } from '../db/database';
import { LocalApiSettings, readDesktopSettings } from '../desktop-settings';
import { authenticate, matchRoute } from './router';
import { clearAllTokens } from './auth-store';
import {
HttpError,
RequestContext,
getBearerToken,
readJsonBody,
sendError,
sendJson,
sendText
} from './http-helpers';
export type LocalApiStatus = 'stopped' | 'starting' | 'running' | 'error';
export interface LocalApiSnapshot {
status: LocalApiStatus;
host: string | null;
port: number | null;
baseUrl: string | null;
error: string | null;
exposeOnLan: boolean;
scalarEnabled: boolean;
docusaurusEnabled: boolean;
}
let server: Server | null = null;
let currentStatus: LocalApiStatus = 'stopped';
let currentBindHost: string | null = null;
let currentBindPort: number | null = null;
let currentError: string | null = null;
let activeSettings: LocalApiSettings | null = null;
function pickBindHost(settings: LocalApiSettings): string {
return settings.exposeOnLan ? '0.0.0.0' : '127.0.0.1';
}
function buildBaseUrl(host: string, port: number): string {
const safeHost = host === '0.0.0.0' ? '127.0.0.1' : host;
return `http://${safeHost}:${port}`;
}
async function sendFile(res: ServerResponse, status: number, filePath: string, contentType: string): Promise<void> {
if (!res.headersSent) {
res.statusCode = status;
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'no-store');
}
await pipeline(createReadStream(filePath), res);
}
export function getLocalApiSnapshot(): LocalApiSnapshot {
const settings = activeSettings ?? readDesktopSettings().localApi;
return {
status: currentStatus,
host: currentBindHost,
port: currentBindPort,
baseUrl: currentBindHost && currentBindPort ? buildBaseUrl(currentBindHost, currentBindPort) : null,
error: currentError,
exposeOnLan: settings.exposeOnLan,
scalarEnabled: settings.scalarEnabled,
docusaurusEnabled: settings.docusaurusEnabled
};
}
async function handleRequest(req: IncomingMessage, res: ServerResponse, settings: LocalApiSettings): Promise<void> {
// CORS for loopback origin only. Local-first; not a public API.
const origin = req.headers.origin;
const allowOrigin = origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/iu.test(origin) ? origin : 'null';
res.setHeader('Access-Control-Allow-Origin', allowOrigin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '600');
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
let urlObj: URL;
try {
urlObj = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
} catch {
sendJson(res, 400, { error: 'Invalid URL', errorCode: 'INVALID_URL' });
return;
}
const requestContext: RequestContext = {
method: (req.method ?? 'GET').toUpperCase(),
url: urlObj,
pathname: urlObj.pathname,
headers: req.headers,
remoteAddress: req.socket.remoteAddress ?? '',
bearerToken: getBearerToken(req.headers)
};
const { match, methodNotAllowed } = matchRoute(requestContext.method, requestContext.pathname);
if (!match) {
if (methodNotAllowed) {
sendJson(res, 405, { error: 'Method not allowed', errorCode: 'METHOD_NOT_ALLOWED' });
} else {
sendJson(res, 404, { error: 'Not found', errorCode: 'NOT_FOUND' });
}
return;
}
if (match.requiresAuth) {
const issued = authenticate(requestContext.bearerToken);
if (!issued) {
sendJson(res, 401, { error: 'Authentication required', errorCode: 'UNAUTHORIZED' });
return;
}
}
const dataSource = getDataSource() ?? null;
if (match.requiresDatabase && (!dataSource || !dataSource.isInitialized)) {
sendJson(res, 503, { error: 'Database not initialised', errorCode: 'DB_UNAVAILABLE' });
return;
}
let bodyCache: unknown | undefined;
try {
const baseUrl = buildBaseUrl(currentBindHost ?? '127.0.0.1', currentBindPort ?? settings.port);
const result = await match.handler({
request: requestContext,
settings,
baseUrl,
dataSource,
bodyBuffer: async () => {
if (bodyCache === undefined) {
bodyCache = await readJsonBody<unknown>(req);
}
return bodyCache;
}
});
if (result.status === 204) {
res.statusCode = 204;
res.end();
return;
}
if (result.filePath) {
await sendFile(res, result.status, result.filePath, result.contentType ?? 'application/octet-stream');
return;
}
if (result.rawBody !== undefined) {
sendText(res, result.status, result.rawBody, result.contentType ?? 'text/plain; charset=utf-8');
return;
}
sendJson(res, result.status, result.body);
} catch (error) {
if (!(error instanceof HttpError)) {
console.error('[LocalApi] Request handler error:', error);
}
sendError(res, error);
}
}
export interface StartResult {
ok: boolean;
snapshot: LocalApiSnapshot;
}
export async function startLocalApiServer(settings: LocalApiSettings): Promise<StartResult> {
if (server) {
await stopLocalApiServer();
}
activeSettings = { ...settings, allowedSignalingServers: [...settings.allowedSignalingServers] };
currentStatus = 'starting';
currentError = null;
currentBindHost = pickBindHost(settings);
currentBindPort = settings.port;
const httpServer = createServer((req, res) => {
void handleRequest(req, res, activeSettings ?? settings).catch((error) => {
console.error('[LocalApi] Unhandled request error:', error);
try {
sendError(res, error);
} catch {
// ignore
}
});
});
return await new Promise<StartResult>((resolve) => {
httpServer.once('error', (error) => {
currentStatus = 'error';
currentError = (error as Error).message;
currentBindPort = null;
server = null;
activeSettings = null;
console.error('[LocalApi] Failed to start:', error);
resolve({ ok: false, snapshot: getLocalApiSnapshot() });
});
httpServer.listen(settings.port, pickBindHost(settings), () => {
const address = httpServer.address() as AddressInfo | null;
server = httpServer;
currentStatus = 'running';
currentBindPort = address?.port ?? settings.port;
currentError = null;
console.log(`[LocalApi] Listening on http://${currentBindHost}:${currentBindPort}`);
resolve({ ok: true, snapshot: getLocalApiSnapshot() });
});
});
}
export async function stopLocalApiServer(): Promise<LocalApiSnapshot> {
const httpServer = server;
if (!httpServer) {
currentStatus = 'stopped';
currentBindHost = null;
currentBindPort = null;
activeSettings = null;
return getLocalApiSnapshot();
}
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
// close() waits for connections; force-close keep-alives so it returns promptly.
httpServer.closeAllConnections?.();
});
server = null;
currentStatus = 'stopped';
currentBindHost = null;
currentBindPort = null;
currentError = null;
activeSettings = null;
clearAllTokens();
console.log('[LocalApi] Stopped');
return getLocalApiSnapshot();
}
export async function applyLocalApiSettings(): Promise<LocalApiSnapshot> {
const settings = readDesktopSettings().localApi;
if (!settings.enabled) {
return await stopLocalApiServer();
}
// If already running with the same bind config, no-op (settings like
// scalarEnabled / allowedSignalingServers are read on every request).
if (
server
&& activeSettings
&& currentStatus === 'running'
&& activeSettings.port === settings.port
&& activeSettings.exposeOnLan === settings.exposeOnLan
) {
activeSettings = { ...settings, allowedSignalingServers: [...settings.allowedSignalingServers] };
return getLocalApiSnapshot();
}
const result = await startLocalApiServer(settings);
return result.snapshot;
}

540
electron/api/openapi.ts Normal file
View File

@@ -0,0 +1,540 @@
export interface OpenApiBuildOptions {
baseUrl: string;
appVersion: string;
}
export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
const { baseUrl, appVersion } = options;
const roomIdPathParameter = { name: 'roomId', in: 'path', required: true, schema: { type: 'string' } };
const userIdPathParameter = { name: 'userId', in: 'path', required: true, schema: { type: 'string' } };
const messageIdPathParameter = { name: 'messageId', in: 'path', required: true, schema: { type: 'string' } };
const sinceTimestampQueryParameter = {
name: 'sinceTimestamp',
in: 'query',
required: true,
schema: { type: 'integer', minimum: 0, format: 'int64' }
};
return {
openapi: '3.1.0',
info: {
title: 'MetoYou Local Desktop API',
version: appVersion,
description:
'Authenticated local HTTP API exposed by the MetoYou desktop app. '
+ 'Authentication is performed against a configured signaling server. '
+ 'Bearer tokens issued here are scoped to this device only.'
},
servers: [{ url: baseUrl }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'opaque'
}
},
schemas: {
Error: {
type: 'object',
required: ['error'],
properties: {
error: { type: 'string' },
errorCode: { type: 'string' }
}
},
LoginRequest: {
type: 'object',
required: [
'username',
'password',
'serverUrl'
],
properties: {
username: { type: 'string' },
password: { type: 'string' },
serverUrl: {
type: 'string',
format: 'uri',
description: 'Base URL of the signaling server to authenticate against. Must be in the allowed list configured in the desktop app.'
}
}
},
LoginResponse: {
type: 'object',
required: [
'token',
'expiresAt',
'user'
],
properties: {
token: { type: 'string' },
expiresAt: { type: 'integer', format: 'int64' },
user: { $ref: '#/components/schemas/AuthUser' }
}
},
AuthUser: {
type: 'object',
required: [
'id',
'username',
'displayName'
],
properties: {
id: { type: 'string' },
username: { type: 'string' },
displayName: { type: 'string' }
}
},
Profile: {
type: 'object',
properties: {
id: { type: 'string' },
username: { type: 'string' },
displayName: { type: 'string' },
description: { type: 'string' },
avatarUrl: { type: 'string' },
status: { type: 'string' }
}
},
Room: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' }
},
additionalProperties: true
},
User: {
type: 'object',
properties: {
id: { type: 'string' },
oderId: { type: 'string' },
username: { type: 'string' },
displayName: { type: 'string' },
status: { type: 'string' },
role: { type: 'string' },
isOnline: { type: 'boolean' }
},
additionalProperties: true
},
Message: {
type: 'object',
properties: {
id: { type: 'string' },
roomId: { type: 'string' },
channelId: { type: 'string' },
senderId: { type: 'string' },
senderName: { type: 'string' },
content: { type: 'string' },
timestamp: { type: 'integer', format: 'int64' },
editedAt: { type: 'integer', format: 'int64' },
isDeleted: { type: 'boolean' }
},
additionalProperties: true
},
Reaction: {
type: 'object',
properties: {
id: { type: 'string' },
messageId: { type: 'string' },
userId: { type: 'string' },
oderId: { type: 'string' },
emoji: { type: 'string' },
timestamp: { type: 'integer', format: 'int64' }
},
additionalProperties: true
},
Attachment: {
type: 'object',
properties: {
id: { type: 'string' },
messageId: { type: 'string' },
filename: { type: 'string' },
size: { type: 'integer' },
mime: { type: 'string' },
isImage: { type: 'boolean' },
filePath: { type: 'string' },
savedPath: { type: 'string' }
},
additionalProperties: true
},
Ban: {
type: 'object',
properties: {
oderId: { type: 'string' },
roomId: { type: 'string' },
userId: { type: 'string' },
bannedBy: { type: 'string' },
displayName: { type: 'string' },
reason: { type: 'string' },
expiresAt: { type: 'integer', format: 'int64' },
timestamp: { type: 'integer', format: 'int64' }
},
additionalProperties: true
},
PluginDataValue: {
type: 'object',
properties: {
value: {}
}
},
MetaValue: {
type: 'object',
properties: {
key: { type: 'string' },
value: { type: ['string', 'null'] }
}
}
}
},
security: [{ bearerAuth: [] }],
paths: {
'/api/health': {
get: {
security: [],
summary: 'Liveness probe',
responses: {
'200': {
description: 'Service is alive',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
status: { type: 'string' },
version: { type: 'string' }
}
}
}
}
}
}
}
},
'/api/openapi.json': {
get: {
security: [],
summary: 'OpenAPI specification',
responses: { '200': { description: 'This document' } }
}
},
'/api/auth/login': {
post: {
security: [],
summary: 'Exchange username/password (validated by a signaling server) for a bearer token',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/LoginRequest' }
}
}
},
responses: {
'200': {
description: 'Token issued',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/LoginResponse' }
}
}
},
'401': {
description: 'Invalid credentials',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Error' } }
}
},
'403': {
description: 'Signaling server URL not allowed',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Error' } }
}
}
}
}
},
'/api/auth/logout': {
post: {
summary: 'Revoke the current bearer token',
responses: { '204': { description: 'Token revoked' } }
}
},
'/api/profile': {
get: {
summary: 'Get the current user profile',
responses: {
'200': {
description: 'Current user profile',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Profile' }
}
}
},
'404': {
description: 'No current user is set on this device',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Error' } }
}
}
}
}
},
'/api/rooms': {
get: {
summary: 'List rooms (servers) known to this device',
responses: {
'200': {
description: 'Rooms array',
content: {
'application/json': {
schema: {
type: 'array',
items: { $ref: '#/components/schemas/Room' }
}
}
}
}
}
}
},
'/api/rooms/{roomId}': {
get: {
summary: 'Get a room by id',
parameters: [roomIdPathParameter],
responses: {
'200': {
description: 'Room details',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Room' }
}
}
},
'404': {
description: 'Room not found',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }
}
}
}
},
'/api/rooms/{roomId}/users': {
get: {
summary: 'List users known for a room',
parameters: [roomIdPathParameter],
responses: {
'200': {
description: 'Users array',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/User' } }
}
}
}
}
}
},
'/api/rooms/{roomId}/messages': {
get: {
summary: 'List messages for a room',
parameters: [
roomIdPathParameter,
{ name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 500 } },
{ name: 'offset', in: 'query', required: false, schema: { type: 'integer', minimum: 0 } }
],
responses: {
'200': {
description: 'Messages array',
content: {
'application/json': {
schema: {
type: 'array',
items: { $ref: '#/components/schemas/Message' }
}
}
}
}
}
}
},
'/api/rooms/{roomId}/messages/since': {
get: {
summary: 'List room messages after a timestamp',
parameters: [roomIdPathParameter, sinceTimestampQueryParameter],
responses: {
'200': {
description: 'Messages array',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/Message' } }
}
}
}
}
}
},
'/api/rooms/{roomId}/bans': {
get: {
summary: 'List active bans for a room',
parameters: [roomIdPathParameter],
responses: {
'200': {
description: 'Bans array',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/Ban' } }
}
}
}
}
}
},
'/api/rooms/{roomId}/bans/{userId}': {
get: {
summary: 'Check whether a user is banned in a room',
parameters: [roomIdPathParameter, userIdPathParameter],
responses: {
'200': {
description: 'Ban status',
content: {
'application/json': {
schema: {
type: 'object',
required: ['isBanned'],
properties: { isBanned: { type: 'boolean' } }
}
}
}
}
}
}
},
'/api/messages/{messageId}': {
get: {
summary: 'Get a message by id',
parameters: [messageIdPathParameter],
responses: {
'200': {
description: 'Message details',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Message' }
}
}
},
'404': {
description: 'Message not found',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }
}
}
}
},
'/api/messages/{messageId}/reactions': {
get: {
summary: 'List reactions for a message',
parameters: [messageIdPathParameter],
responses: {
'200': {
description: 'Reactions array',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/Reaction' } }
}
}
}
}
}
},
'/api/messages/{messageId}/attachments': {
get: {
summary: 'List attachments for a message',
parameters: [messageIdPathParameter],
responses: {
'200': {
description: 'Attachments array',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/Attachment' } }
}
}
}
}
}
},
'/api/users/{userId}': {
get: {
summary: 'Get a user by id',
parameters: [userIdPathParameter],
responses: {
'200': {
description: 'User details',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/User' }
}
}
},
'404': {
description: 'User not found',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }
}
}
}
},
'/api/attachments': {
get: {
summary: 'List all attachments stored on this device',
responses: {
'200': {
description: 'Attachments array',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/Attachment' } }
}
}
}
}
}
},
'/api/plugin-data': {
get: {
summary: 'Read a plugin data value',
parameters: [
{ name: 'pluginId', in: 'query', required: true, schema: { type: 'string' } },
{ name: 'key', in: 'query', required: true, schema: { type: 'string' } },
{ name: 'scope', in: 'query', required: true, schema: { type: 'string', enum: ['local', 'server'] } },
{ name: 'serverId', in: 'query', required: false, schema: { type: 'string' } }
],
responses: {
'200': {
description: 'Plugin data value',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/PluginDataValue' }
}
}
}
}
}
},
'/api/meta/{key}': {
get: {
summary: 'Read a desktop metadata value',
parameters: [{ name: 'key', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Metadata value',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/MetaValue' }
}
}
}
}
}
}
}
};
}

511
electron/api/router.ts Normal file
View File

@@ -0,0 +1,511 @@
import { app, net } from 'electron';
import { DataSource } from 'typeorm';
import { buildQueryHandlers } from '../cqrs/queries';
import {
QueryType,
QueryTypeKey,
Query
} from '../cqrs/types';
import {
issueToken,
consumeToken,
revokeToken,
IssuedToken
} from './auth-store';
import { buildOpenApiDocument } from './openapi';
import { HttpError, RequestContext } from './http-helpers';
import { getDocsHtml, getScalarApiReferenceBundlePath } from './docs-html';
import { resolveDocusaurusRoute } from './docusaurus-static';
import { LocalApiSettings } from '../desktop-settings';
export interface RouteResponse {
status: number;
body: unknown;
contentType?: string;
filePath?: string;
rawBody?: string;
}
export interface RouteContext {
request: RequestContext;
settings: LocalApiSettings;
baseUrl: string;
dataSource: DataSource | null;
bodyBuffer: () => Promise<unknown>;
}
type RouteHandler = (context: RouteContext) => Promise<RouteResponse>;
interface RouteMatch {
handler: RouteHandler;
params: Record<string, string>;
requiresAuth: boolean;
requiresDatabase: boolean;
}
interface RouteDefinition {
method: string;
pattern: RegExp;
paramKeys: string[];
handler: RouteHandler;
requiresAuth: boolean;
requiresDatabase: boolean;
}
function compilePattern(template: string): { pattern: RegExp; paramKeys: string[] } {
const paramKeys: string[] = [];
const escaped = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => {
if (match === '*' || match === '+' || match === '?')
return `\\${match}`;
return `\\${match}`;
});
const source = template.replace(/\{([^}]+)\}/g, (_full, key: string) => {
paramKeys.push(key);
return '([^/]+)';
});
void escaped;
return { pattern: new RegExp(`^${source}$`), paramKeys };
}
function defineRoute(method: string, template: string, handler: RouteHandler, requiresAuth: boolean, requiresDatabase = true): RouteDefinition {
const compiled = compilePattern(template);
return { method: method.toUpperCase(), pattern: compiled.pattern, paramKeys: compiled.paramKeys, handler, requiresAuth, requiresDatabase };
}
function runQuery<T>(dataSource: DataSource, query: Query): Promise<T> {
const handlers = buildQueryHandlers(dataSource) as Record<QueryTypeKey, (q: Query) => Promise<unknown>>;
const handler = handlers[query.type as QueryTypeKey];
if (!handler) {
throw new HttpError(500, `No handler registered for query: ${query.type}`, 'UNKNOWN_QUERY');
}
return handler(query) as Promise<T>;
}
function requireDataSource(dataSource: DataSource | null): DataSource {
if (!dataSource) {
throw new HttpError(503, 'Database not initialised', 'DB_UNAVAILABLE');
}
return dataSource;
}
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
const parsed = typeof value === 'string' ? Number(value) : NaN;
if (!Number.isFinite(parsed))
return fallback;
return Math.max(min, Math.min(max, Math.floor(parsed)));
}
function getTrailingPathParam(pathname: string, pattern: RegExp, name: string): string {
const value = pattern.exec(pathname)?.[1];
if (!value) {
throw new HttpError(400, `${name} is required`, 'INVALID_REQUEST');
}
return decodeURIComponent(value);
}
function getRequiredQueryParam(ctx: RouteContext, name: string): string {
const value = ctx.request.url.searchParams.get(name)?.trim() ?? '';
if (!value) {
throw new HttpError(400, `${name} is required`, 'INVALID_REQUEST');
}
return value;
}
function getRequiredTimestamp(ctx: RouteContext, name: string): number {
const raw = getRequiredQueryParam(ctx, name);
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
throw new HttpError(400, `${name} must be a non-negative timestamp`, 'INVALID_REQUEST');
}
return Math.floor(value);
}
const ROUTES: RouteDefinition[] = [
defineRoute('GET', '/api/health', async (ctx): Promise<RouteResponse> => ({
status: 200,
body: { status: 'ok', version: app.getVersion(), timestamp: Date.now(), exposeOnLan: ctx.settings.exposeOnLan }
}), false, false),
defineRoute('GET', '/api/openapi.json', async (ctx): Promise<RouteResponse> => ({
status: 200,
body: buildOpenApiDocument({ baseUrl: ctx.baseUrl, appVersion: app.getVersion() })
}), false, false),
defineRoute('GET', '/docs', async (ctx): Promise<RouteResponse> => {
if (!ctx.settings.scalarEnabled) {
return {
status: 404,
body: null,
contentType: 'text/plain; charset=utf-8',
rawBody: 'API documentation is disabled. Enable Scalar in desktop settings to view it.'
};
}
return {
status: 200,
body: null,
contentType: 'text/html; charset=utf-8',
rawBody: getDocsHtml(`${ctx.baseUrl}/api/openapi.json`)
};
}, false, false),
defineRoute('GET', '/docusaurus(?:/.*)?', async (ctx): Promise<RouteResponse> => {
if (!ctx.settings.docusaurusEnabled) {
return {
status: 404,
body: null,
contentType: 'text/plain; charset=utf-8',
rawBody: 'Docusaurus documentation is disabled. Open documentation from the desktop app to activate it.'
};
}
const docsRoute = await resolveDocusaurusRoute(ctx.request.pathname);
return {
status: 200,
body: null,
contentType: docsRoute.contentType,
filePath: docsRoute.filePath
};
}, false, false),
defineRoute('GET', '/scalar/api-reference.js', async (ctx): Promise<RouteResponse> => {
if (!ctx.settings.scalarEnabled) {
return {
status: 404,
body: null,
contentType: 'text/plain; charset=utf-8',
rawBody: 'API documentation is disabled. Enable Scalar in desktop settings to view it.'
};
}
const bundlePath = await getScalarApiReferenceBundlePath();
if (!bundlePath) {
throw new HttpError(503, 'Scalar API reference bundle is not available in this build', 'SCALAR_BUNDLE_MISSING');
}
return {
status: 200,
body: null,
contentType: 'application/javascript; charset=utf-8',
filePath: bundlePath
};
}, false, false),
defineRoute('POST', '/api/auth/login', async (ctx): Promise<RouteResponse> => {
const body = await ctx.bodyBuffer() as { username?: unknown; password?: unknown; serverUrl?: unknown };
const username = typeof body.username === 'string' ? body.username.trim() : '';
const password = typeof body.password === 'string' ? body.password : '';
const serverUrl = typeof body.serverUrl === 'string' ? body.serverUrl.trim().replace(/\/+$/u, '') : '';
if (!username || !password || !serverUrl) {
throw new HttpError(400, 'username, password, and serverUrl are required', 'INVALID_REQUEST');
}
if (!/^https?:\/\//iu.test(serverUrl)) {
throw new HttpError(400, 'serverUrl must be an http or https URL', 'INVALID_REQUEST');
}
if (ctx.settings.allowedSignalingServers.length === 0) {
throw new HttpError(403, 'No signaling servers are allowed for local API authentication. Add one in desktop settings.', 'NO_ALLOWED_SERVERS');
}
if (!ctx.settings.allowedSignalingServers.includes(serverUrl)) {
throw new HttpError(403, 'Signaling server URL is not in the allowed list', 'SERVER_NOT_ALLOWED');
}
let response: Response;
try {
response = await net.fetch(`${serverUrl}/api/users/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
} catch (error) {
throw new HttpError(502, `Failed to reach signaling server: ${(error as Error).message}`, 'UPSTREAM_UNREACHABLE');
}
if (response.status === 401 || response.status === 403) {
throw new HttpError(401, 'Invalid credentials', 'INVALID_CREDENTIALS');
}
if (!response.ok) {
throw new HttpError(502, `Signaling server rejected login (${response.status})`, 'UPSTREAM_ERROR');
}
const remote = await response.json() as { id?: string; username?: string; displayName?: string };
if (!remote.id || !remote.username) {
throw new HttpError(502, 'Signaling server returned an unexpected response', 'UPSTREAM_BAD_RESPONSE');
}
const issued = issueToken({
userId: remote.id,
username: remote.username,
displayName: remote.displayName ?? remote.username,
signalingServerUrl: serverUrl
});
return {
status: 200,
body: {
token: issued.token,
expiresAt: issued.expiresAt,
user: {
id: issued.userId,
username: issued.username,
displayName: issued.displayName
}
}
};
}, false),
defineRoute('POST', '/api/auth/logout', async (ctx): Promise<RouteResponse> => {
if (ctx.request.bearerToken) {
revokeToken(ctx.request.bearerToken);
}
return { status: 204, body: null };
}, true),
defineRoute('GET', '/api/profile', async (ctx): Promise<RouteResponse> => {
const user = await runQuery<unknown>(requireDataSource(ctx.dataSource), {
type: QueryType.GetCurrentUser,
payload: {}
});
if (!user) {
throw new HttpError(404, 'No current user is set on this device', 'NO_CURRENT_USER');
}
return { status: 200, body: user };
}, true),
defineRoute('GET', '/api/rooms', async (ctx): Promise<RouteResponse> => {
const rooms = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetAllRooms,
payload: {}
});
return { status: 200, body: rooms ?? [] };
}, true),
defineRoute('GET', '/api/rooms/{roomId}', async (ctx): Promise<RouteResponse> => {
const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)$/u, 'roomId');
const room = await runQuery<unknown>(requireDataSource(ctx.dataSource), {
type: QueryType.GetRoom,
payload: { roomId }
});
if (!room) {
throw new HttpError(404, 'Room not found on this device', 'ROOM_NOT_FOUND');
}
return { status: 200, body: room };
}, true),
defineRoute('GET', '/api/rooms/{roomId}/users', async (ctx): Promise<RouteResponse> => {
const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)\/users$/u, 'roomId');
const users = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetUsersByRoom,
payload: { roomId }
});
return { status: 200, body: users ?? [] };
}, true),
defineRoute('GET', '/api/rooms/{roomId}/messages', async (ctx): Promise<RouteResponse> => {
const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)\/messages$/u, 'roomId');
const limit = clampInt(ctx.request.url.searchParams.get('limit'), 1, 500, 100);
const offset = clampInt(ctx.request.url.searchParams.get('offset'), 0, Number.MAX_SAFE_INTEGER, 0);
const messages = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetMessages,
payload: { roomId, limit, offset }
});
return { status: 200, body: messages ?? [] };
}, true),
defineRoute('GET', '/api/rooms/{roomId}/messages/since', async (ctx): Promise<RouteResponse> => {
const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)\/messages\/since$/u, 'roomId');
const sinceTimestamp = getRequiredTimestamp(ctx, 'sinceTimestamp');
const messages = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetMessagesSince,
payload: { roomId, sinceTimestamp }
});
return { status: 200, body: messages ?? [] };
}, true),
defineRoute('GET', '/api/rooms/{roomId}/bans', async (ctx): Promise<RouteResponse> => {
const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)\/bans$/u, 'roomId');
const bans = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetBansForRoom,
payload: { roomId }
});
return { status: 200, body: bans ?? [] };
}, true),
defineRoute('GET', '/api/rooms/{roomId}/bans/{userId}', async (ctx): Promise<RouteResponse> => {
const match = /\/api\/rooms\/([^/]+)\/bans\/([^/]+)$/u.exec(ctx.request.pathname);
if (!match) {
throw new HttpError(400, 'roomId and userId are required', 'INVALID_REQUEST');
}
const isBanned = await runQuery<boolean>(requireDataSource(ctx.dataSource), {
type: QueryType.IsUserBanned,
payload: { roomId: decodeURIComponent(match[1]), userId: decodeURIComponent(match[2]) }
});
return { status: 200, body: { isBanned } };
}, true),
defineRoute('GET', '/api/messages/{messageId}', async (ctx): Promise<RouteResponse> => {
const messageId = getTrailingPathParam(ctx.request.pathname, /\/api\/messages\/([^/]+)$/u, 'messageId');
const message = await runQuery<unknown>(requireDataSource(ctx.dataSource), {
type: QueryType.GetMessageById,
payload: { messageId }
});
if (!message) {
throw new HttpError(404, 'Message not found on this device', 'MESSAGE_NOT_FOUND');
}
return { status: 200, body: message };
}, true),
defineRoute('GET', '/api/messages/{messageId}/reactions', async (ctx): Promise<RouteResponse> => {
const messageId = getTrailingPathParam(ctx.request.pathname, /\/api\/messages\/([^/]+)\/reactions$/u, 'messageId');
const reactions = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetReactionsForMessage,
payload: { messageId }
});
return { status: 200, body: reactions ?? [] };
}, true),
defineRoute('GET', '/api/messages/{messageId}/attachments', async (ctx): Promise<RouteResponse> => {
const messageId = getTrailingPathParam(ctx.request.pathname, /\/api\/messages\/([^/]+)\/attachments$/u, 'messageId');
const attachments = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetAttachmentsForMessage,
payload: { messageId }
});
return { status: 200, body: attachments ?? [] };
}, true),
defineRoute('GET', '/api/users/{userId}', async (ctx): Promise<RouteResponse> => {
const userId = getTrailingPathParam(ctx.request.pathname, /\/api\/users\/([^/]+)$/u, 'userId');
const user = await runQuery<unknown>(requireDataSource(ctx.dataSource), {
type: QueryType.GetUser,
payload: { userId }
});
if (!user) {
throw new HttpError(404, 'User not found on this device', 'USER_NOT_FOUND');
}
return { status: 200, body: user };
}, true),
defineRoute('GET', '/api/attachments', async (ctx): Promise<RouteResponse> => {
const attachments = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetAllAttachments,
payload: {}
});
return { status: 200, body: attachments ?? [] };
}, true),
defineRoute('GET', '/api/plugin-data', async (ctx): Promise<RouteResponse> => {
const pluginId = getRequiredQueryParam(ctx, 'pluginId');
const key = getRequiredQueryParam(ctx, 'key');
const scope = getRequiredQueryParam(ctx, 'scope');
if (scope !== 'local' && scope !== 'server') {
throw new HttpError(400, 'scope must be local or server', 'INVALID_REQUEST');
}
const value = await runQuery<unknown>(requireDataSource(ctx.dataSource), {
type: QueryType.GetPluginData,
payload: {
key,
pluginId,
scope,
serverId: ctx.request.url.searchParams.get('serverId') ?? undefined
}
});
return { status: 200, body: { value } };
}, true),
defineRoute('GET', '/api/meta/{key}', async (ctx): Promise<RouteResponse> => {
const key = getTrailingPathParam(ctx.request.pathname, /\/api\/meta\/([^/]+)$/u, 'key');
const value = await runQuery<string | null>(requireDataSource(ctx.dataSource), {
type: QueryType.GetMeta,
payload: { key }
});
return { status: 200, body: { key, value } };
}, true)
];
export interface RoutingResult {
match: RouteMatch | null;
methodNotAllowed: boolean;
}
export function matchRoute(method: string, pathname: string): RoutingResult {
let methodNotAllowed = false;
for (const route of ROUTES) {
const result = route.pattern.exec(pathname);
if (!result)
continue;
if (route.method !== method) {
methodNotAllowed = true;
continue;
}
const params: Record<string, string> = {};
for (let index = 0; index < route.paramKeys.length; index++) {
params[route.paramKeys[index]] = result[index + 1];
}
return {
match: { handler: route.handler, params, requiresAuth: route.requiresAuth, requiresDatabase: route.requiresDatabase },
methodNotAllowed: false
};
}
return { match: null, methodNotAllowed };
}
export function authenticate(token: string | null): IssuedToken | null {
if (!token)
return null;
return consumeToken(token);
}

View File

@@ -5,6 +5,7 @@ import { createWindow, getMainWindow } from '../window/create-window';
const CUSTOM_PROTOCOL = 'toju'; const CUSTOM_PROTOCOL = 'toju';
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`; const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE'; const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
let pendingDeepLink: string | null = null; let pendingDeepLink: string | null = null;
@@ -95,6 +96,12 @@ export function initializeDeepLinkHandling(): boolean {
} }
app.on('second-instance', (_event, argv) => { app.on('second-instance', (_event, argv) => {
if (resolveDevSingleInstanceExitCode() != null && argv.includes(DEV_RELOAD_EXISTING_ARG)) {
app.relaunch();
app.exit(0);
return;
}
focusMainWindow(); focusMainWindow();
const deepLink = extractDeepLink(argv); const deepLink = extractDeepLink(argv);

View File

@@ -2,6 +2,7 @@ import { app, BrowserWindow } from 'electron';
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing'; import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater'; import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
import { synchronizeAutoStartSetting } from './auto-start'; import { synchronizeAutoStartSetting } from './auto-start';
import { applyLocalApiSettings, stopLocalApiServer } from '../api';
import { import {
initializeDatabase, initializeDatabase,
destroyDatabase, destroyDatabase,
@@ -19,6 +20,15 @@ import {
setupSystemHandlers, setupSystemHandlers,
setupWindowControlHandlers setupWindowControlHandlers
} from '../ipc'; } from '../ipc';
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
function startLocalApiAfterWindowReady(): void {
setImmediate(() => {
void applyLocalApiSettings().catch((error: unknown) => {
console.error('[LocalApi] Failed to apply settings after window startup:', error);
});
});
}
export function registerAppLifecycle(): void { export function registerAppLifecycle(): void {
app.whenReady().then(async () => { app.whenReady().then(async () => {
@@ -34,6 +44,8 @@ export function registerAppLifecycle(): void {
await synchronizeAutoStartSetting(); await synchronizeAutoStartSetting();
initializeDesktopUpdater(); initializeDesktopUpdater();
await createWindow(); await createWindow();
startLocalApiAfterWindowReady();
startIdleMonitor();
app.on('activate', () => { app.on('activate', () => {
if (getMainWindow()) { if (getMainWindow()) {
@@ -57,6 +69,8 @@ export function registerAppLifecycle(): void {
if (getDataSource()?.isInitialized) { if (getDataSource()?.isInitialized) {
event.preventDefault(); event.preventDefault();
shutdownDesktopUpdater(); shutdownDesktopUpdater();
stopIdleMonitor();
await stopLocalApiServer();
await cleanupLinuxScreenShareAudioRouting(); await cleanupLinuxScreenShareAudioRouting();
await destroyDatabase(); await destroyDatabase();
app.quit(); app.quit();

View File

@@ -3,18 +3,30 @@ import {
MessageEntity, MessageEntity,
UserEntity, UserEntity,
RoomEntity, RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,
MetaEntity MetaEntity,
PluginDataEntity
} from '../../../entities'; } from '../../../entities';
export async function handleClearAllData(dataSource: DataSource): Promise<void> { export async function handleClearAllData(dataSource: DataSource): Promise<void> {
await dataSource.getRepository(MessageEntity).clear(); await dataSource.getRepository(MessageEntity).clear();
await dataSource.getRepository(UserEntity).clear(); await dataSource.getRepository(UserEntity).clear();
await dataSource.getRepository(RoomEntity).clear(); await dataSource.getRepository(RoomEntity).clear();
await dataSource.getRepository(RoomChannelEntity).clear();
await dataSource.getRepository(RoomMemberEntity).clear();
await dataSource.getRepository(RoomRoleEntity).clear();
await dataSource.getRepository(RoomUserRoleEntity).clear();
await dataSource.getRepository(RoomChannelPermissionEntity).clear();
await dataSource.getRepository(ReactionEntity).clear(); await dataSource.getRepository(ReactionEntity).clear();
await dataSource.getRepository(BanEntity).clear(); await dataSource.getRepository(BanEntity).clear();
await dataSource.getRepository(AttachmentEntity).clear(); await dataSource.getRepository(AttachmentEntity).clear();
await dataSource.getRepository(MetaEntity).clear(); await dataSource.getRepository(MetaEntity).clear();
await dataSource.getRepository(PluginDataEntity).clear();
} }

View File

@@ -1,9 +1,15 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities'; import { MessageEntity } from '../../../entities';
import { ClearRoomMessagesCommand } from '../../types'; import { ClearRoomMessagesCommand } from '../../types';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleClearRoomMessages(command: ClearRoomMessagesCommand, dataSource: DataSource): Promise<void> { export async function handleClearRoomMessages(command: ClearRoomMessagesCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity); const repo = dataSource.getRepository(MessageEntity);
const currentUserId = await getCurrentUserScope(dataSource);
await repo.delete({ roomId: command.payload.roomId }); if (!currentUserId) {
return;
}
await repo.delete({ roomId: command.payload.roomId, ownerUserId: currentUserId });
} }

View File

@@ -1,9 +1,15 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities'; import { MessageEntity } from '../../../entities';
import { DeleteMessageCommand } from '../../types'; import { DeleteMessageCommand } from '../../types';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleDeleteMessage(command: DeleteMessageCommand, dataSource: DataSource): Promise<void> { export async function handleDeleteMessage(command: DeleteMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity); const repo = dataSource.getRepository(MessageEntity);
const currentUserId = await getCurrentUserScope(dataSource);
await repo.delete({ id: command.payload.messageId }); if (!currentUserId) {
return;
}
await repo.delete({ id: command.payload.messageId, ownerUserId: currentUserId });
} }

View File

@@ -0,0 +1,21 @@
import { DataSource } from 'typeorm';
import { getCurrentUserScope } from '../../current-user-scope';
import { PluginDataEntity } from '../../../entities';
import { DeletePluginDataCommand } from '../../types';
export async function handleDeletePluginData(command: DeletePluginDataCommand, dataSource: DataSource): Promise<void> {
const { payload } = command;
const ownerUserId = await getCurrentUserScope(dataSource);
if (!ownerUserId) {
return;
}
await dataSource.getRepository(PluginDataEntity).delete({
key: payload.key,
ownerUserId,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? ''
});
}

View File

@@ -1,10 +1,41 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { RoomEntity, MessageEntity } from '../../../entities'; import {
RoomChannelPermissionEntity,
RoomChannelEntity,
RoomEntity,
RoomOwnerEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
MessageEntity
} from '../../../entities';
import { DeleteRoomCommand } from '../../types'; import { DeleteRoomCommand } from '../../types';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> { export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
const { roomId } = command.payload; const { roomId } = command.payload;
await dataSource.getRepository(RoomEntity).delete({ id: roomId }); await dataSource.transaction(async (manager) => {
await dataSource.getRepository(MessageEntity).delete({ roomId }); const currentUserId = await getCurrentUserScope(manager);
if (!currentUserId) {
return;
}
await manager.getRepository(RoomOwnerEntity).delete({ roomId, userId: currentUserId });
await manager.getRepository(MessageEntity).delete({ roomId, ownerUserId: currentUserId });
const remainingOwners = await manager.getRepository(RoomOwnerEntity).count({ where: { roomId } });
if (remainingOwners > 0) {
return;
}
await manager.getRepository(RoomChannelPermissionEntity).delete({ roomId });
await manager.getRepository(RoomChannelEntity).delete({ roomId });
await manager.getRepository(RoomMemberEntity).delete({ roomId });
await manager.getRepository(RoomRoleEntity).delete({ roomId });
await manager.getRepository(RoomUserRoleEntity).delete({ roomId });
await manager.getRepository(RoomEntity).delete({ id: roomId });
});
} }

View File

@@ -1,23 +1,31 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities'; import { MessageEntity } from '../../../entities';
import { replaceMessageReactions } from '../../relations';
import { SaveMessageCommand } from '../../types'; import { SaveMessageCommand } from '../../types';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> { export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { message } = command.payload; const { message } = command.payload;
await dataSource.transaction(async (manager) => {
const currentUserId = await getCurrentUserScope(manager);
const repo = manager.getRepository(MessageEntity);
const entity = repo.create({ const entity = repo.create({
id: message.id, id: message.id,
roomId: message.roomId, roomId: message.roomId,
ownerUserId: currentUserId,
channelId: message.channelId ?? null, channelId: message.channelId ?? null,
senderId: message.senderId, senderId: message.senderId,
senderName: message.senderName, senderName: message.senderName,
content: message.content, content: message.content,
timestamp: message.timestamp, timestamp: message.timestamp,
editedAt: message.editedAt ?? null, editedAt: message.editedAt ?? null,
reactions: JSON.stringify(message.reactions ?? []),
isDeleted: message.isDeleted ? 1 : 0, isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null replyToId: message.replyToId ?? null,
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null
}); });
await repo.save(entity); await repo.save(entity);
await replaceMessageReactions(manager, message.id, message.reactions ?? []);
});
} }

View File

@@ -0,0 +1,10 @@
import { DataSource } from 'typeorm';
import { MetaEntity } from '../../../entities';
import { SaveMetaCommand } from '../../types';
export async function handleSaveMeta(command: SaveMetaCommand, dataSource: DataSource): Promise<void> {
await dataSource.getRepository(MetaEntity).save({
key: command.payload.key,
value: command.payload.value
});
}

View File

@@ -0,0 +1,23 @@
import { DataSource } from 'typeorm';
import { getCurrentUserScope } from '../../current-user-scope';
import { PluginDataEntity } from '../../../entities';
import { SavePluginDataCommand } from '../../types';
export async function handleSavePluginData(command: SavePluginDataCommand, dataSource: DataSource): Promise<void> {
const { payload } = command;
const ownerUserId = await getCurrentUserScope(dataSource);
if (!ownerUserId) {
return;
}
await dataSource.getRepository(PluginDataEntity).save({
key: payload.key,
ownerUserId,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? '',
updatedAt: Date.now(),
valueJson: JSON.stringify(payload.value ?? null)
});
}

View File

@@ -1,10 +1,29 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities'; import { RoomEntity, RoomOwnerEntity } from '../../../entities';
import { replaceRoomRelations } from '../../relations';
import { SaveRoomCommand } from '../../types'; import { SaveRoomCommand } from '../../types';
import { getCurrentUserScope } from '../../current-user-scope';
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
if (typeof room.slowModeInterval === 'number' && Number.isFinite(room.slowModeInterval)) {
return room.slowModeInterval;
}
const permissions = room.permissions && typeof room.permissions === 'object'
? room.permissions as { slowModeInterval?: unknown }
: null;
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
? permissions.slowModeInterval
: 0;
}
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> { export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { room } = command.payload; const { room } = command.payload;
await dataSource.transaction(async (manager) => {
const currentUserId = await getCurrentUserScope(manager);
const repo = manager.getRepository(RoomEntity);
const entity = repo.create({ const entity = repo.create({
id: room.id, id: room.id,
name: room.name, name: room.name,
@@ -19,13 +38,29 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
maxUsers: room.maxUsers ?? null, maxUsers: room.maxUsers ?? null,
icon: room.icon ?? null, icon: room.icon ?? null,
iconUpdatedAt: room.iconUpdatedAt ?? null, iconUpdatedAt: room.iconUpdatedAt ?? null,
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null, slowModeInterval: extractSlowModeInterval(room),
channels: room.channels != null ? JSON.stringify(room.channels) : null,
members: room.members != null ? JSON.stringify(room.members) : null,
sourceId: room.sourceId ?? null, sourceId: room.sourceId ?? null,
sourceName: room.sourceName ?? null, sourceName: room.sourceName ?? null,
sourceUrl: room.sourceUrl ?? null sourceUrl: room.sourceUrl ?? null
}); });
await repo.save(entity); await repo.save(entity);
if (currentUserId) {
await manager.getRepository(RoomOwnerEntity).save({
roomId: room.id,
userId: currentUserId,
savedAt: Date.now()
});
}
await replaceRoomRelations(manager, room.id, {
channels: room.channels ?? [],
members: room.members ?? [],
roles: room.roles ?? [],
roleAssignments: room.roleAssignments ?? [],
channelPermissions: room.channelPermissions ?? [],
permissions: room.permissions
});
});
} }

View File

@@ -10,7 +10,12 @@ export async function handleSaveUser(command: SaveUserCommand, dataSource: DataS
oderId: user.oderId ?? null, oderId: user.oderId ?? null,
username: user.username ?? null, username: user.username ?? null,
displayName: user.displayName ?? null, displayName: user.displayName ?? null,
description: user.description ?? null,
profileUpdatedAt: user.profileUpdatedAt ?? null,
avatarUrl: user.avatarUrl ?? null, avatarUrl: user.avatarUrl ?? null,
avatarHash: user.avatarHash ?? null,
avatarMime: user.avatarMime ?? null,
avatarUpdatedAt: user.avatarUpdatedAt ?? null,
status: user.status ?? null, status: user.status ?? null,
role: user.role ?? null, role: user.role ?? null,
joinedAt: user.joinedAt ?? null, joinedAt: user.joinedAt ?? null,

View File

@@ -1,41 +1,59 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities'; import { MessageEntity } from '../../../entities';
import { replaceMessageReactions } from '../../relations';
import { UpdateMessageCommand } from '../../types'; import { UpdateMessageCommand } from '../../types';
import { getCurrentUserScope } from '../../current-user-scope';
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> { export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { messageId, updates } = command.payload; const { messageId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: messageId } });
await dataSource.transaction(async (manager) => {
const currentUserId = await getCurrentUserScope(manager);
if (!currentUserId) {
return;
}
const repo = manager.getRepository(MessageEntity);
const existing = await repo.findOne({ where: { id: messageId, ownerUserId: currentUserId } });
if (!existing) if (!existing)
return; return;
if (updates.channelId !== undefined) const directFields = [
existing.channelId = updates.channelId ?? null; 'senderId',
'senderName',
'content',
'timestamp'
] as const;
const entity = existing as unknown as Record<string, unknown>;
if (updates.senderId !== undefined) for (const field of directFields) {
existing.senderId = updates.senderId; if (updates[field] !== undefined)
entity[field] = updates[field];
}
if (updates.senderName !== undefined) const nullableFields = [
existing.senderName = updates.senderName; 'channelId',
'editedAt',
'replyToId'
] as const;
if (updates.content !== undefined) for (const field of nullableFields) {
existing.content = updates.content; if (updates[field] !== undefined)
entity[field] = updates[field] ?? null;
if (updates.timestamp !== undefined) }
existing.timestamp = updates.timestamp;
if (updates.editedAt !== undefined)
existing.editedAt = updates.editedAt ?? null;
if (updates.reactions !== undefined)
existing.reactions = JSON.stringify(updates.reactions ?? []);
if (updates.isDeleted !== undefined) if (updates.isDeleted !== undefined)
existing.isDeleted = updates.isDeleted ? 1 : 0; existing.isDeleted = updates.isDeleted ? 1 : 0;
if (updates.replyToId !== undefined) if (updates.linkMetadata !== undefined)
existing.replyToId = updates.replyToId ?? null; existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;
await repo.save(existing); await repo.save(existing);
if (updates.reactions !== undefined) {
await replaceMessageReactions(manager, messageId, updates.reactions ?? []);
}
});
} }

View File

@@ -1,31 +1,75 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities'; import { RoomEntity } from '../../../entities';
import { replaceRoomRelations } from '../../relations';
import { UpdateRoomCommand } from '../../types'; import { UpdateRoomCommand } from '../../types';
import { import {
applyUpdates, applyUpdates,
boolToInt, boolToInt,
jsonOrNull,
TransformMap TransformMap
} from './utils/applyUpdates'; } from './utils/applyUpdates';
import { getCurrentUserScope, userOwnsRoom } from '../../current-user-scope';
const ROOM_TRANSFORMS: TransformMap = { const ROOM_TRANSFORMS: TransformMap = {
hasPassword: boolToInt, hasPassword: boolToInt,
isPrivate: boolToInt, isPrivate: boolToInt,
userCount: (val) => (val ?? 0), userCount: (val) => (val ?? 0)
permissions: jsonOrNull,
channels: jsonOrNull,
members: jsonOrNull
}; };
function extractSlowModeInterval(updates: UpdateRoomCommand['payload']['updates']): number | undefined {
if (typeof updates.slowModeInterval === 'number' && Number.isFinite(updates.slowModeInterval)) {
return updates.slowModeInterval;
}
const permissions = updates.permissions && typeof updates.permissions === 'object'
? updates.permissions as { slowModeInterval?: unknown }
: null;
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
? permissions.slowModeInterval
: undefined;
}
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> { export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { roomId, updates } = command.payload; const { roomId, updates } = command.payload;
await dataSource.transaction(async (manager) => {
const currentUserId = await getCurrentUserScope(manager);
if (!await userOwnsRoom(manager, roomId, currentUserId)) {
return;
}
const repo = manager.getRepository(RoomEntity);
const existing = await repo.findOne({ where: { id: roomId } }); const existing = await repo.findOne({ where: { id: roomId } });
if (!existing) if (!existing)
return; return;
applyUpdates(existing, updates, ROOM_TRANSFORMS); const {
await repo.save(existing); channels,
members,
roles,
roleAssignments,
channelPermissions,
permissions: rawPermissions,
...entityUpdates
} = updates;
const slowModeInterval = extractSlowModeInterval(updates);
if (slowModeInterval !== undefined) {
entityUpdates.slowModeInterval = slowModeInterval;
}
applyUpdates(existing, entityUpdates, ROOM_TRANSFORMS);
await repo.save(existing);
await replaceRoomRelations(manager, roomId, {
channels,
members,
roles,
roleAssignments,
channelPermissions,
permissions: rawPermissions
});
});
} }

Some files were not shown because too many files have changed in this diff Show More