Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53389ed3ad | |||
| 3858beb28e | |||
| 1b91eacb5b | |||
| 11c2588e45 | |||
| bc2fa7de22 | |||
| 44588e8789 | |||
| 167c45ba8d | |||
| bd21568726 | |||
| 3ba8a2c9eb | |||
| 28797a0141 | |||
| 17738ec484 | |||
| 35b616fb77 | |||
| 2927a86fbb | |||
| b4ac0cdc92 | |||
| f3b56fb1cc | |||
| 315820d487 | |||
| 878fd1c766 |
@@ -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
|
||||||
|
|||||||
@@ -48,18 +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') }}
|
||||||
|
restore-keys: npm-linux-
|
||||||
|
|
||||||
|
- name: Restore Electron cache
|
||||||
|
uses: https://github.com/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/root/.cache/electron
|
||||||
|
/root/.cache/electron-builder
|
||||||
|
key: electron-linux-${{ hashFiles('package.json') }}
|
||||||
|
restore-keys: electron-linux-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
run: |
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends zip
|
||||||
npm ci
|
npm ci
|
||||||
cd server && npm ci
|
cd server && npm ci
|
||||||
|
|
||||||
- name: Install zip utility
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y zip
|
|
||||||
|
|
||||||
- name: Set CI release version
|
- name: Set CI release version
|
||||||
run: >
|
run: >
|
||||||
node tools/set-release-version.js
|
node tools/set-release-version.js
|
||||||
@@ -108,6 +120,22 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Restore npm cache
|
||||||
|
uses: https://github.com/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/AppData/Local/npm-cache
|
||||||
|
key: npm-windows-${{ hashFiles('package-lock.json', 'server/package-lock.json') }}
|
||||||
|
restore-keys: npm-windows-
|
||||||
|
|
||||||
|
- name: Restore Electron cache
|
||||||
|
uses: https://github.com/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/AppData/Local/electron/Cache
|
||||||
|
~/AppData/Local/electron-builder/Cache
|
||||||
|
key: electron-windows-${{ hashFiles('package.json') }}
|
||||||
|
restore-keys: electron-windows-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
@@ -217,9 +245,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 }}
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -60,3 +60,8 @@ dist-server/*
|
|||||||
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
doc/**
|
doc/**
|
||||||
|
|
||||||
|
metoyou.sqlite*
|
||||||
|
metoyou.sqlite
|
||||||
|
|
||||||
|
vitest/
|
||||||
|
|||||||
165
README.md
165
README.md
@@ -1,119 +1,88 @@
|
|||||||
<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) |
|
||||||
|
|
||||||
## 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. 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:electron` builds the Electron code to `dist/electron`.
|
||||||
|
- `npm run build:all` builds the product client, 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 |
|
| `tools/` | Build, release, formatting, and packaging scripts |
|
||||||
| `src/assets/` | Static assets |
|
|
||||||
| `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) |
|
|
||||||
|
|||||||
36
e2e/README.md
Normal file
36
e2e/README.md
Normal 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/`.
|
||||||
@@ -5,23 +5,15 @@ import {
|
|||||||
type BrowserContext,
|
type BrowserContext,
|
||||||
type Browser
|
type Browser
|
||||||
} from '@playwright/test';
|
} from '@playwright/test';
|
||||||
import { spawn, type ChildProcess } from 'node:child_process';
|
|
||||||
import { once } from 'node:events';
|
|
||||||
import { createServer } from 'node:net';
|
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { installTestServerEndpoint } from '../helpers/seed-test-endpoint';
|
import { installTestServerEndpoint } from '../helpers/seed-test-endpoint';
|
||||||
|
import { startTestServer, type TestServerHandle } from '../helpers/test-server';
|
||||||
|
|
||||||
export interface Client {
|
export interface Client {
|
||||||
page: Page;
|
page: Page;
|
||||||
context: BrowserContext;
|
context: BrowserContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TestServerHandle {
|
|
||||||
port: number;
|
|
||||||
url: string;
|
|
||||||
stop: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MultiClientFixture {
|
interface MultiClientFixture {
|
||||||
createClient: () => Promise<Client>;
|
createClient: () => Promise<Client>;
|
||||||
testServer: TestServerHandle;
|
testServer: TestServerHandle;
|
||||||
@@ -31,10 +23,9 @@ const FAKE_AUDIO_FILE = join(__dirname, 'test-tone.wav');
|
|||||||
const CHROMIUM_FAKE_MEDIA_ARGS = [
|
const CHROMIUM_FAKE_MEDIA_ARGS = [
|
||||||
'--use-fake-device-for-media-stream',
|
'--use-fake-device-for-media-stream',
|
||||||
'--use-fake-ui-for-media-stream',
|
'--use-fake-ui-for-media-stream',
|
||||||
`--use-file-for-fake-audio-capture=${FAKE_AUDIO_FILE}`
|
`--use-file-for-fake-audio-capture=${FAKE_AUDIO_FILE}`,
|
||||||
|
'--autoplay-policy=no-user-gesture-required'
|
||||||
];
|
];
|
||||||
const E2E_DIR = join(__dirname, '..');
|
|
||||||
const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
|
|
||||||
|
|
||||||
export const test = base.extend<MultiClientFixture>({
|
export const test = base.extend<MultiClientFixture>({
|
||||||
testServer: async ({ playwright: _playwright }, use: (testServer: TestServerHandle) => Promise<void>) => {
|
testServer: async ({ playwright: _playwright }, use: (testServer: TestServerHandle) => Promise<void>) => {
|
||||||
@@ -81,122 +72,3 @@ export const test = base.extend<MultiClientFixture>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export { expect } from '@playwright/test';
|
export { expect } from '@playwright/test';
|
||||||
|
|
||||||
async function startTestServer(retries = 3): Promise<TestServerHandle> {
|
|
||||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
||||||
const port = await allocatePort();
|
|
||||||
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
|
|
||||||
cwd: E2E_DIR,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TEST_SERVER_PORT: String(port)
|
|
||||||
},
|
|
||||||
stdio: 'pipe'
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stdout?.on('data', (chunk: Buffer | string) => {
|
|
||||||
process.stdout.write(chunk.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr?.on('data', (chunk: Buffer | string) => {
|
|
||||||
process.stderr.write(chunk.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForServerReady(port, child);
|
|
||||||
} catch (error) {
|
|
||||||
await stopServer(child);
|
|
||||||
|
|
||||||
if (attempt < retries) {
|
|
||||||
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
port,
|
|
||||||
url: `http://localhost:${port}`,
|
|
||||||
stop: async () => {
|
|
||||||
await stopServer(child);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('startTestServer: unreachable');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function allocatePort(): Promise<number> {
|
|
||||||
return new Promise<number>((resolve, reject) => {
|
|
||||||
const probe = createServer();
|
|
||||||
|
|
||||||
probe.once('error', reject);
|
|
||||||
probe.listen(0, '127.0.0.1', () => {
|
|
||||||
const address = probe.address();
|
|
||||||
|
|
||||||
if (!address || typeof address === 'string') {
|
|
||||||
probe.close();
|
|
||||||
reject(new Error('Failed to resolve an ephemeral test server port'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { port } = address;
|
|
||||||
|
|
||||||
probe.close((error) => {
|
|
||||||
if (error) {
|
|
||||||
reject(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForServerReady(port: number, child: ChildProcess, timeoutMs = 30_000): Promise<void> {
|
|
||||||
const readyUrl = `http://127.0.0.1:${port}/api/servers?limit=1`;
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (child.exitCode !== null) {
|
|
||||||
throw new Error(`Test server exited before becoming ready (exit code ${child.exitCode})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(readyUrl);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Server still starting.
|
|
||||||
}
|
|
||||||
|
|
||||||
await wait(250);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Timed out waiting for test server on port ${port}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopServer(child: ChildProcess): Promise<void> {
|
|
||||||
if (child.exitCode !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
child.kill('SIGTERM');
|
|
||||||
|
|
||||||
const exited = await Promise.race([once(child, 'exit').then(() => true), wait(3_000).then(() => false)]);
|
|
||||||
|
|
||||||
if (!exited && child.exitCode === null) {
|
|
||||||
child.kill('SIGKILL');
|
|
||||||
await once(child, 'exit');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function wait(durationMs: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, durationMs);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,16 @@ import { type BrowserContext, type Page } from '@playwright/test';
|
|||||||
const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
|
const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
|
||||||
const REMOVED_DEFAULT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
|
const REMOVED_DEFAULT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
|
||||||
|
|
||||||
type SeededEndpointStorageState = {
|
export interface SeededEndpointInput {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
isDefault?: boolean;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeededEndpointStorageState {
|
||||||
key: string;
|
key: string;
|
||||||
removedKey: string;
|
removedKey: string;
|
||||||
endpoints: {
|
endpoints: {
|
||||||
@@ -14,24 +23,35 @@ type SeededEndpointStorageState = {
|
|||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
}
|
||||||
|
|
||||||
function buildSeededEndpointStorageState(
|
function buildSeededEndpointStorageState(
|
||||||
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
endpointsOrPort: readonly SeededEndpointInput[] | number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||||
): SeededEndpointStorageState {
|
): SeededEndpointStorageState {
|
||||||
const endpoint = {
|
const endpoints = Array.isArray(endpointsOrPort)
|
||||||
id: 'e2e-test-server',
|
? endpointsOrPort.map((endpoint) => ({
|
||||||
name: 'E2E Test Server',
|
id: endpoint.id,
|
||||||
url: `http://localhost:${port}`,
|
name: endpoint.name,
|
||||||
isActive: true,
|
url: endpoint.url,
|
||||||
isDefault: false,
|
isActive: endpoint.isActive ?? true,
|
||||||
status: 'unknown'
|
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 {
|
return {
|
||||||
key: SERVER_ENDPOINTS_STORAGE_KEY,
|
key: SERVER_ENDPOINTS_STORAGE_KEY,
|
||||||
removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY,
|
removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY,
|
||||||
endpoints: [endpoint]
|
endpoints
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +60,11 @@ function applySeededEndpointStorageState(storageState: SeededEndpointStorageStat
|
|||||||
const storage = window.localStorage;
|
const storage = window.localStorage;
|
||||||
|
|
||||||
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
|
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
|
||||||
storage.setItem(storageState.removedKey, JSON.stringify(['default', 'toju-primary', 'toju-sweden']));
|
storage.setItem(storageState.removedKey, JSON.stringify([
|
||||||
|
'default',
|
||||||
|
'toju-primary',
|
||||||
|
'toju-sweden'
|
||||||
|
]));
|
||||||
} catch {
|
} catch {
|
||||||
// about:blank and some Playwright UI pages deny localStorage access.
|
// about:blank and some Playwright UI pages deny localStorage access.
|
||||||
}
|
}
|
||||||
@@ -55,11 +79,20 @@ export async function installTestServerEndpoint(
|
|||||||
await context.addInitScript(applySeededEndpointStorageState, storageState);
|
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.
|
* 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)
|
* 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
|
* 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
|
* relied upon - calling it in the first goto() landing page is fine since the
|
||||||
* page will re-read on next navigation/reload).
|
* page will re-read on next navigation/reload).
|
||||||
*
|
*
|
||||||
* Typical usage:
|
* Typical usage:
|
||||||
@@ -75,3 +108,12 @@ export async function seedTestServerEndpoint(
|
|||||||
|
|
||||||
await page.evaluate(applySeededEndpointStorageState, storageState);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
|
|||||||
const SERVER_DIR = join(__dirname, '..', '..', 'server');
|
const SERVER_DIR = join(__dirname, '..', '..', 'server');
|
||||||
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
|
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
|
||||||
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
|
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 ──────────────────────────────
|
// ── Create isolated temp data directory ──────────────────────────────
|
||||||
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
||||||
@@ -43,8 +44,8 @@ console.log(`[E2E Server] Starting on port ${TEST_PORT}...`);
|
|||||||
// Module resolution (require/import) uses __dirname, so server source
|
// Module resolution (require/import) uses __dirname, so server source
|
||||||
// and node_modules are found from the real server/ directory.
|
// and node_modules are found from the real server/ directory.
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
'npx',
|
process.execPath,
|
||||||
['ts-node', '--project', SERVER_TSCONFIG, SERVER_ENTRY],
|
[TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY],
|
||||||
{
|
{
|
||||||
cwd: tmpDir,
|
cwd: tmpDir,
|
||||||
env: {
|
env: {
|
||||||
@@ -55,7 +56,6 @@ const child = spawn(
|
|||||||
DB_SYNCHRONIZE: 'true',
|
DB_SYNCHRONIZE: 'true',
|
||||||
},
|
},
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
shell: true,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
132
e2e/helpers/test-server.ts
Normal file
132
e2e/helpers/test-server.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,9 +11,15 @@ import { type Page } from '@playwright/test';
|
|||||||
export async function installWebRTCTracking(page: Page): Promise<void> {
|
export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const connections: RTCPeerConnection[] = [];
|
const connections: RTCPeerConnection[] = [];
|
||||||
|
const syntheticMediaResources: {
|
||||||
|
audioCtx: AudioContext;
|
||||||
|
source?: AudioScheduledSourceNode;
|
||||||
|
drawIntervalId?: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
(window as any).__rtcConnections = connections;
|
(window as any).__rtcConnections = connections;
|
||||||
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
||||||
|
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
|
||||||
|
|
||||||
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
||||||
|
|
||||||
@@ -40,48 +46,6 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
|||||||
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
||||||
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
|
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
|
||||||
|
|
||||||
// Patch getUserMedia to use an AudioContext oscillator for audio
|
|
||||||
// instead of the hardware capture device. Chromium's fake audio
|
|
||||||
// device intermittently fails to produce frames after renegotiation.
|
|
||||||
const origGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
|
||||||
|
|
||||||
navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => {
|
|
||||||
const wantsAudio = !!constraints?.audio;
|
|
||||||
|
|
||||||
if (!wantsAudio) {
|
|
||||||
return origGetUserMedia(constraints);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the original stream (may include video)
|
|
||||||
const originalStream = await origGetUserMedia(constraints);
|
|
||||||
const audioCtx = new AudioContext();
|
|
||||||
const oscillator = audioCtx.createOscillator();
|
|
||||||
|
|
||||||
oscillator.frequency.value = 440;
|
|
||||||
|
|
||||||
const dest = audioCtx.createMediaStreamDestination();
|
|
||||||
|
|
||||||
oscillator.connect(dest);
|
|
||||||
oscillator.start();
|
|
||||||
|
|
||||||
const synthAudioTrack = dest.stream.getAudioTracks()[0];
|
|
||||||
const resultStream = new MediaStream();
|
|
||||||
|
|
||||||
resultStream.addTrack(synthAudioTrack);
|
|
||||||
|
|
||||||
// Keep any video tracks from the original stream
|
|
||||||
for (const videoTrack of originalStream.getVideoTracks()) {
|
|
||||||
resultStream.addTrack(videoTrack);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop original audio tracks since we're not using them
|
|
||||||
for (const track of originalStream.getAudioTracks()) {
|
|
||||||
track.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultStream;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Patch getDisplayMedia to return a synthetic screen share stream
|
// Patch getDisplayMedia to return a synthetic screen share stream
|
||||||
// (canvas-based video + 880Hz oscillator audio) so the browser
|
// (canvas-based video + 880Hz oscillator audio) so the browser
|
||||||
// picker dialog is never shown.
|
// picker dialog is never shown.
|
||||||
@@ -128,10 +92,32 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
|||||||
osc.connect(dest);
|
osc.connect(dest);
|
||||||
osc.start();
|
osc.start();
|
||||||
|
|
||||||
|
if (audioCtx.state === 'suspended') {
|
||||||
|
try {
|
||||||
|
await audioCtx.resume();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const audioTrack = dest.stream.getAudioTracks()[0];
|
const audioTrack = dest.stream.getAudioTracks()[0];
|
||||||
// Combine video + audio into one stream
|
// Combine video + audio into one stream
|
||||||
const resultStream = new MediaStream([videoTrack, audioTrack]);
|
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
|
// Tag the stream so tests can identify it
|
||||||
(resultStream as any).__isScreenShare = true;
|
(resultStream as any).__isScreenShare = true;
|
||||||
|
|
||||||
@@ -143,6 +129,48 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Wait until at least one RTCPeerConnection reaches the 'connected' state.
|
* 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> {
|
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => (window as any).__rtcConnections?.some(
|
() => (window as any).__rtcConnections?.some(
|
||||||
@@ -163,6 +191,177 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
* Get outbound and inbound audio RTP stats aggregated across all peer
|
||||||
* connections. Uses a per-connection high water mark stored on `window` so
|
* connections. Uses a per-connection high water mark stored on `window` so
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import {
|
|||||||
type Page
|
type Page
|
||||||
} from '@playwright/test';
|
} from '@playwright/test';
|
||||||
|
|
||||||
export type ChatDropFilePayload = {
|
export interface ChatDropFilePayload {
|
||||||
name: string;
|
name: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
base64: string;
|
base64: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export class ChatMessagesPage {
|
export class ChatMessagesPage {
|
||||||
readonly composer: Locator;
|
readonly composer: Locator;
|
||||||
@@ -115,7 +115,8 @@ export class ChatMessagesPage {
|
|||||||
getEmbedCardByTitle(title: string): Locator {
|
getEmbedCardByTitle(title: string): Locator {
|
||||||
return this.page.locator('app-chat-link-embed').filter({
|
return this.page.locator('app-chat-link-embed').filter({
|
||||||
has: this.page.getByText(title, { exact: true })
|
has: this.page.getByText(title, { exact: true })
|
||||||
}).last();
|
})
|
||||||
|
.last();
|
||||||
}
|
}
|
||||||
|
|
||||||
async editOwnMessage(originalText: string, updatedText: string): Promise<void> {
|
async editOwnMessage(originalText: string, updatedText: string): Promise<void> {
|
||||||
|
|||||||
@@ -19,13 +19,65 @@ export class ChatRoomPage {
|
|||||||
|
|
||||||
/** Click a voice channel by name in the channels sidebar to join voice. */
|
/** Click a voice channel by name in the channels sidebar to join voice. */
|
||||||
async joinVoiceChannel(channelName: string) {
|
async joinVoiceChannel(channelName: string) {
|
||||||
const channelButton = this.page.locator('app-rooms-side-panel')
|
const channelButton = this.getVoiceChannelButton(channelName);
|
||||||
.getByRole('button', { name: channelName, exact: true });
|
|
||||||
|
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 expect(channelButton).toBeVisible({ timeout: 15_000 });
|
||||||
await channelButton.click();
|
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. */
|
/** Click a text channel by name in the channels sidebar to switch chat rooms. */
|
||||||
async joinTextChannel(channelName: string) {
|
async joinTextChannel(channelName: string) {
|
||||||
const channelButton = this.getTextChannelButton(channelName);
|
const channelButton = this.getTextChannelButton(channelName);
|
||||||
@@ -100,6 +152,11 @@ export class ChatRoomPage {
|
|||||||
return this.voiceControls.locator('button:has(ng-icon[name="lucideMic"]), button:has(ng-icon[name="lucideMicOff"])').first();
|
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 the disconnect/hang-up button (destructive styled). */
|
||||||
get disconnectButton() {
|
get disconnectButton() {
|
||||||
return this.voiceControls.locator('button:has(ng-icon[name="lucidePhoneOff"])').first();
|
return this.voiceControls.locator('button:has(ng-icon[name="lucidePhoneOff"])').first();
|
||||||
@@ -112,10 +169,9 @@ export class ChatRoomPage {
|
|||||||
|
|
||||||
/** Get the count of voice users listed under a voice channel. */
|
/** Get the count of voice users listed under a voice channel. */
|
||||||
async getVoiceUserCountInChannel(channelName: string): Promise<number> {
|
async getVoiceUserCountInChannel(channelName: string): Promise<number> {
|
||||||
const channelSection = this.page.locator('app-rooms-side-panel')
|
// The voice channel button is inside a wrapper div; user avatars are siblings within that wrapper
|
||||||
.getByRole('button', { name: channelName })
|
const channelWrapper = this.getVoiceChannelButton(channelName).locator('xpath=ancestor::div[1]');
|
||||||
.locator('..');
|
const userAvatars = channelWrapper.locator('app-user-avatar');
|
||||||
const userAvatars = channelSection.locator('app-user-avatar');
|
|
||||||
|
|
||||||
return userAvatars.count();
|
return userAvatars.count();
|
||||||
}
|
}
|
||||||
@@ -154,9 +210,11 @@ export class ChatRoomPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getTextChannelButton(channelName: string): Locator {
|
private getTextChannelButton(channelName: string): Locator {
|
||||||
const channelPattern = new RegExp(`#\\s*${escapeRegExp(channelName)}$`, 'i');
|
return this.channelsSidePanel.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`).first();
|
||||||
|
}
|
||||||
|
|
||||||
return this.channelsSidePanel.getByRole('button', { name: channelPattern }).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> {
|
private async createTextChannelThroughComponent(channelName: string): Promise<void> {
|
||||||
@@ -384,7 +442,3 @@ export class ChatRoomPage {
|
|||||||
await this.page.waitForTimeout(500);
|
await this.page.waitForTimeout(500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type Page, type Locator } from '@playwright/test';
|
import { type Page, type Locator } from '@playwright/test';
|
||||||
|
|
||||||
export class LoginPage {
|
export class LoginPage {
|
||||||
|
readonly form: Locator;
|
||||||
readonly usernameInput: Locator;
|
readonly usernameInput: Locator;
|
||||||
readonly passwordInput: Locator;
|
readonly passwordInput: Locator;
|
||||||
readonly serverSelect: Locator;
|
readonly serverSelect: Locator;
|
||||||
@@ -9,12 +10,13 @@ export class LoginPage {
|
|||||||
readonly registerLink: Locator;
|
readonly registerLink: Locator;
|
||||||
|
|
||||||
constructor(private page: Page) {
|
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.usernameInput = page.locator('#login-username');
|
||||||
this.passwordInput = page.locator('#login-password');
|
this.passwordInput = page.locator('#login-password');
|
||||||
this.serverSelect = page.locator('#login-server');
|
this.serverSelect = page.locator('#login-server');
|
||||||
this.submitButton = page.getByRole('button', { name: 'Login' });
|
this.submitButton = this.form.getByRole('button', { name: 'Login' });
|
||||||
this.errorText = page.locator('.text-destructive');
|
this.errorText = page.locator('.text-destructive');
|
||||||
this.registerLink = page.getByRole('button', { name: 'Register' });
|
this.registerLink = this.form.getByRole('button', { name: 'Register' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async goto() {
|
async goto() {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { expect, type Page, type Locator } from '@playwright/test';
|
import {
|
||||||
|
expect,
|
||||||
|
type Page,
|
||||||
|
type Locator
|
||||||
|
} from '@playwright/test';
|
||||||
|
|
||||||
export class RegisterPage {
|
export class RegisterPage {
|
||||||
readonly usernameInput: Locator;
|
readonly usernameInput: Locator;
|
||||||
@@ -25,11 +29,12 @@ export class RegisterPage {
|
|||||||
try {
|
try {
|
||||||
await expect(this.usernameInput).toBeVisible({ timeout: 10_000 });
|
await expect(this.usernameInput).toBeVisible({ timeout: 10_000 });
|
||||||
} catch {
|
} catch {
|
||||||
// Angular router may redirect to /login on first load; click through.
|
// Angular router may redirect to /login on first load; use the
|
||||||
const registerLink = this.page.getByRole('link', { name: 'Register' })
|
// visible login-form action instead of broad text matching.
|
||||||
.or(this.page.getByText('Register'));
|
const registerButton = this.page.getByRole('button', { name: 'Register', exact: true }).last();
|
||||||
|
|
||||||
await registerLink.first().click();
|
await expect(registerButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await registerButton.click();
|
||||||
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
|
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,8 +43,11 @@ export class RegisterPage {
|
|||||||
|
|
||||||
async register(username: string, displayName: string, password: string) {
|
async register(username: string, displayName: string, password: string) {
|
||||||
await this.usernameInput.fill(username);
|
await this.usernameInput.fill(username);
|
||||||
|
await expect(this.usernameInput).toHaveValue(username);
|
||||||
await this.displayNameInput.fill(displayName);
|
await this.displayNameInput.fill(displayName);
|
||||||
|
await expect(this.displayNameInput).toHaveValue(displayName);
|
||||||
await this.passwordInput.fill(password);
|
await this.passwordInput.fill(password);
|
||||||
|
await expect(this.passwordInput).toHaveValue(password);
|
||||||
await this.submitButton.click();
|
await this.submitButton.click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
export class ServerSearchPage {
|
export class ServerSearchPage {
|
||||||
readonly searchInput: Locator;
|
readonly searchInput: Locator;
|
||||||
readonly createServerButton: Locator;
|
readonly createServerButton: Locator;
|
||||||
|
readonly railCreateServerButton: Locator;
|
||||||
|
readonly searchCreateServerButton: Locator;
|
||||||
readonly settingsButton: Locator;
|
readonly settingsButton: Locator;
|
||||||
|
|
||||||
// Create server dialog
|
// Create server dialog
|
||||||
@@ -20,8 +22,10 @@ export class ServerSearchPage {
|
|||||||
readonly dialogCancelButton: Locator;
|
readonly dialogCancelButton: Locator;
|
||||||
|
|
||||||
constructor(private page: Page) {
|
constructor(private page: Page) {
|
||||||
this.searchInput = page.getByPlaceholder('Search servers...');
|
this.searchInput = page.getByPlaceholder('Search servers and users...');
|
||||||
this.createServerButton = page.getByRole('button', { name: 'Create New Server' });
|
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"]');
|
this.settingsButton = page.locator('button[title="Settings"]');
|
||||||
|
|
||||||
// Create dialog elements
|
// Create dialog elements
|
||||||
@@ -39,8 +43,20 @@ export class ServerSearchPage {
|
|||||||
await this.page.goto('/search');
|
await this.page.goto('/search');
|
||||||
}
|
}
|
||||||
|
|
||||||
async createServer(name: string, options?: { description?: string; topic?: string }) {
|
async createServer(name: string, options?: { description?: string; topic?: string; sourceId?: string }) {
|
||||||
await this.createServerButton.click();
|
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 expect(this.serverNameInput).toBeVisible();
|
||||||
await this.serverNameInput.fill(name);
|
await this.serverNameInput.fill(name);
|
||||||
|
|
||||||
@@ -52,6 +68,10 @@ export class ServerSearchPage {
|
|||||||
await this.serverTopicInput.fill(options.topic);
|
await this.serverTopicInput.fill(options.topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.sourceId) {
|
||||||
|
await this.signalEndpointSelect.selectOption(options.sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
await this.dialogCreateButton.click();
|
await this.dialogCreateButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +80,11 @@ export class ServerSearchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async joinServerFromSearch(name: string) {
|
async joinServerFromSearch(name: string) {
|
||||||
await this.page.locator('button', { hasText: name }).click();
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ export default defineConfig({
|
|||||||
expect: { timeout: 10_000 },
|
expect: { timeout: 10_000 },
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
reporter: [['html', { outputFolder: '../test-results/html-report' }], ['list']],
|
reporter: [['html', { outputFolder: '../test-results/html-report', open: 'never' }], ['list']],
|
||||||
outputDir: '../test-results/artifacts',
|
outputDir: '../test-results/artifacts',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:4200',
|
baseURL: 'http://localhost:4200',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
video: 'on-first-retry',
|
video: 'on-first-retry',
|
||||||
actionTimeout: 15_000,
|
actionTimeout: 15_000
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
@@ -25,15 +25,16 @@ export default defineConfig({
|
|||||||
args: [
|
args: [
|
||||||
'--use-fake-device-for-media-stream',
|
'--use-fake-device-for-media-stream',
|
||||||
'--use-fake-ui-for-media-stream',
|
'--use-fake-ui-for-media-stream',
|
||||||
],
|
'--autoplay-policy=no-user-gesture-required'
|
||||||
},
|
]
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'cd ../toju-app && npx ng serve',
|
command: 'cd ../toju-app && npx ng serve',
|
||||||
port: 4200,
|
url: 'http://localhost:4200',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
269
e2e/tests/auth/user-session-data-isolation.spec.ts
Normal file
269
e2e/tests/auth/user-session-data-isolation.spec.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
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 roomButton = getSavedRoomButton(page, roomName);
|
||||||
|
const messagesPage = new ChatMessagesPage(page);
|
||||||
|
|
||||||
|
await expect(roomButton).toBeVisible({ timeout: 20_000 });
|
||||||
|
await roomButton.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(getSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
|
||||||
|
await expect(getSavedRoomButton(page, roomName)).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSavedRoomButton(page: Page, roomName: string) {
|
||||||
|
return page.locator(`button[title="${roomName}"]`).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { type Page } from '@playwright/test';
|
import { type Page } from '@playwright/test';
|
||||||
import { test, expect, type Client } from '../../fixtures/multi-client';
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
type Client
|
||||||
|
} from '../../fixtures/multi-client';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
import {
|
import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
|
||||||
ChatMessagesPage,
|
|
||||||
type ChatDropFilePayload
|
|
||||||
} from '../../pages/chat-messages.page';
|
|
||||||
|
|
||||||
const MOCK_EMBED_URL = 'https://example.test/mock-embed';
|
const MOCK_EMBED_URL = 'https://example.test/mock-embed';
|
||||||
const MOCK_EMBED_TITLE = 'Mock Embed Title';
|
const MOCK_EMBED_TITLE = 'Mock Embed Title';
|
||||||
@@ -17,6 +18,85 @@ const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
|||||||
test.describe('Chat messaging features', () => {
|
test.describe('Chat messaging features', () => {
|
||||||
test.describe.configure({ timeout: 180_000 });
|
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 }) => {
|
test('syncs messages in a newly created text channel', async ({ createClient }) => {
|
||||||
const scenario = await createChatScenario(createClient);
|
const scenario = await createChatScenario(createClient);
|
||||||
const channelName = uniqueName('updates');
|
const channelName = uniqueName('updates');
|
||||||
@@ -133,14 +213,51 @@ test.describe('Chat messaging features', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
type ChatScenario = {
|
interface ChatScenario {
|
||||||
alice: Client;
|
alice: Client;
|
||||||
bob: Client;
|
bob: Client;
|
||||||
aliceRoom: ChatRoomPage;
|
aliceRoom: ChatRoomPage;
|
||||||
bobRoom: ChatRoomPage;
|
bobRoom: ChatRoomPage;
|
||||||
aliceMessages: ChatMessagesPage;
|
aliceMessages: ChatMessagesPage;
|
||||||
bobMessages: 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> {
|
async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> {
|
||||||
const suffix = uniqueName('chat');
|
const suffix = uniqueName('chat');
|
||||||
@@ -170,6 +287,7 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
|||||||
aliceCredentials.displayName,
|
aliceCredentials.displayName,
|
||||||
aliceCredentials.password
|
aliceCredentials.password
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||||
|
|
||||||
await bobRegisterPage.goto();
|
await bobRegisterPage.goto();
|
||||||
@@ -178,6 +296,7 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
|||||||
bobCredentials.displayName,
|
bobCredentials.displayName,
|
||||||
bobCredentials.password
|
bobCredentials.password
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||||
|
|
||||||
const aliceSearchPage = new ServerSearchPage(alice.page);
|
const aliceSearchPage = new ServerSearchPage(alice.page);
|
||||||
@@ -185,14 +304,12 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
|||||||
await aliceSearchPage.createServer(serverName, {
|
await aliceSearchPage.createServer(serverName, {
|
||||||
description: 'E2E chat server for messaging feature coverage'
|
description: 'E2E chat server for messaging feature coverage'
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
const bobSearchPage = new ServerSearchPage(bob.page);
|
const bobSearchPage = new ServerSearchPage(bob.page);
|
||||||
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
|
|
||||||
|
|
||||||
await bobSearchPage.searchInput.fill(serverName);
|
await bobSearchPage.joinServerFromSearch(serverName);
|
||||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
|
||||||
await serverCard.click();
|
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
const aliceRoom = new ChatRoomPage(alice.page);
|
||||||
@@ -213,6 +330,52 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
async function installChatFeatureMocks(page: Page): Promise<void> {
|
||||||
await page.route('**/api/klipy/config', async (route) => {
|
await page.route('**/api/klipy/config', async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
@@ -259,6 +422,7 @@ async function installChatFeatureMocks(page: Page): Promise<void> {
|
|||||||
siteName: 'Mock Docs'
|
siteName: 'Mock Docs'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,5 +455,6 @@ function buildMockSvgMarkup(label: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uniqueName(prefix: string): string {
|
function uniqueName(prefix: string): string {
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
114
e2e/tests/chat/dm-flow.spec.ts
Normal file
114
e2e/tests/chat/dm-flow.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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('shows friend and message actions on the search people list', async ({ createClient }) => {
|
||||||
|
const scenario = await createDmScenario(createClient);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
}
|
||||||
260
e2e/tests/chat/notifications.spec.ts
Normal file
260
e2e/tests/chat/notifications.spec.ts
Normal 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[];
|
||||||
|
}
|
||||||
711
e2e/tests/chat/profile-avatar-sync.spec.ts
Normal file
711
e2e/tests/chat/profile-avatar-sync.spec.ts
Normal file
@@ -0,0 +1,711 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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: 20_000,
|
||||||
|
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: 20_000,
|
||||||
|
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: 20_000,
|
||||||
|
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: 20_000,
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
waitForVideoFlow,
|
waitForVideoFlow,
|
||||||
waitForOutboundVideoFlow,
|
waitForOutboundVideoFlow,
|
||||||
waitForInboundVideoFlow,
|
waitForInboundVideoFlow,
|
||||||
dumpRtcDiagnostics
|
dumpRtcDiagnostics,
|
||||||
|
installAutoResumeAudioContext
|
||||||
} from '../../helpers/webrtc-helpers';
|
} from '../../helpers/webrtc-helpers';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
@@ -38,7 +39,7 @@ async function registerUser(page: import('@playwright/test').Page, user: typeof
|
|||||||
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Both users register → Alice creates server → Bob joins. */
|
/** Both users register -> Alice creates server -> Bob joins. */
|
||||||
async function setupServerWithBothUsers(
|
async function setupServerWithBothUsers(
|
||||||
alice: { page: import('@playwright/test').Page },
|
alice: { page: import('@playwright/test').Page },
|
||||||
bob: { page: import('@playwright/test').Page }
|
bob: { page: import('@playwright/test').Page }
|
||||||
@@ -55,12 +56,7 @@ async function setupServerWithBothUsers(
|
|||||||
// Bob joins server
|
// Bob joins server
|
||||||
const bobSearch = new ServerSearchPage(bob.page);
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
await bobSearch.searchInput.fill(SERVER_NAME);
|
await bobSearch.joinServerFromSearch(SERVER_NAME);
|
||||||
|
|
||||||
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
|
|
||||||
|
|
||||||
await expect(serverCard).toBeVisible({ timeout: 10_000 });
|
|
||||||
await serverCard.click();
|
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,19 +76,45 @@ async function joinVoiceTogether(
|
|||||||
await expect(existingChannel).toBeVisible({ timeout: 10_000 });
|
await expect(existingChannel).toBeVisible({ timeout: 10_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
|
|
||||||
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
const bobRoom = new ChatRoomPage(bob.page);
|
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 bobRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// Wait for WebRTC + audio pipeline
|
// Wait for WebRTC + audio pipeline
|
||||||
await waitForPeerConnected(alice.page, 30_000);
|
await waitForPeerConnected(alice.page, 30_000);
|
||||||
await waitForPeerConnected(bob.page, 30_000);
|
await waitForPeerConnected(bob.page, 30_000);
|
||||||
await waitForAudioStatsPresent(alice.page, 20_000);
|
await waitForAudioStatsPresent(alice.page, 20_000);
|
||||||
await waitForAudioStatsPresent(bob.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
|
// Expand voice workspace on both clients so the demand-driven screen
|
||||||
// share request flow can fire (requires connectRemoteShares = true).
|
// share request flow can fire (requires connectRemoteShares = true).
|
||||||
@@ -142,6 +164,20 @@ test.describe('Screen sharing', () => {
|
|||||||
|
|
||||||
await installWebRTCTracking(alice.page);
|
await installWebRTCTracking(alice.page);
|
||||||
await installWebRTCTracking(bob.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()));
|
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
||||||
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
||||||
@@ -251,6 +287,18 @@ test.describe('Screen sharing', () => {
|
|||||||
|
|
||||||
await installWebRTCTracking(alice.page);
|
await installWebRTCTracking(alice.page);
|
||||||
await installWebRTCTracking(bob.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()));
|
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
||||||
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
||||||
@@ -323,6 +371,18 @@ test.describe('Screen sharing', () => {
|
|||||||
|
|
||||||
await installWebRTCTracking(alice.page);
|
await installWebRTCTracking(alice.page);
|
||||||
await installWebRTCTracking(bob.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()));
|
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
||||||
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
||||||
|
|||||||
219
e2e/tests/settings/connectivity-warning.spec.ts
Normal file
219
e2e/tests/settings/connectivity-warning.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
127
e2e/tests/settings/ice-server-settings.spec.ts
Normal file
127
e2e/tests/settings/ice-server-settings.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
212
e2e/tests/settings/stun-turn-fallback.spec.ts
Normal file
212
e2e/tests/settings/stun-turn-fallback.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
803
e2e/tests/voice/mixed-signal-config-voice.spec.ts
Normal file
803
e2e/tests/voice/mixed-signal-config-voice.spec.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
763
e2e/tests/voice/multi-signal-eight-user-voice.spec.ts
Normal file
763
e2e/tests/voice/multi-signal-eight-user-voice.spec.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, expect } from '../../fixtures/multi-client';
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
import {
|
import {
|
||||||
installWebRTCTracking,
|
installWebRTCTracking,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
waitForPeerConnected,
|
waitForPeerConnected,
|
||||||
isPeerStillConnected,
|
isPeerStillConnected,
|
||||||
getAudioStatsDelta,
|
getAudioStatsDelta,
|
||||||
@@ -13,7 +14,7 @@ import { ServerSearchPage } from '../../pages/server-search.page';
|
|||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full user journey: register → create server → join → voice → verify audio
|
* Full user journey: register -> create server -> join -> voice -> verify audio
|
||||||
* for 10+ seconds of stable connectivity.
|
* for 10+ seconds of stable connectivity.
|
||||||
*
|
*
|
||||||
* Uses two independent browser contexts (Alice & Bob) to simulate real
|
* Uses two independent browser contexts (Alice & Bob) to simulate real
|
||||||
@@ -25,7 +26,7 @@ const BOB = { username: `bob_${Date.now()}`, displayName: 'Bob', password: 'Test
|
|||||||
const SERVER_NAME = `E2E Test Server ${Date.now()}`;
|
const SERVER_NAME = `E2E Test Server ${Date.now()}`;
|
||||||
const VOICE_CHANNEL = 'General';
|
const VOICE_CHANNEL = 'General';
|
||||||
|
|
||||||
test.describe('Full user journey: register → server → voice chat', () => {
|
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('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
|
test.setTimeout(180_000); // 3 min - covers registration, server creation, voice establishment, and 10s stability check
|
||||||
|
|
||||||
@@ -35,6 +36,20 @@ test.describe('Full user journey: register → server → voice chat', () => {
|
|||||||
// Install WebRTC tracking before any navigation
|
// Install WebRTC tracking before any navigation
|
||||||
await installWebRTCTracking(alice.page);
|
await installWebRTCTracking(alice.page);
|
||||||
await installWebRTCTracking(bob.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
|
// Forward browser console for debugging
|
||||||
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
||||||
@@ -81,14 +96,7 @@ test.describe('Full user journey: register → server → voice chat', () => {
|
|||||||
await test.step('Bob finds and joins the server', async () => {
|
await test.step('Bob finds and joins the server', async () => {
|
||||||
const searchPage = new ServerSearchPage(bob.page);
|
const searchPage = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
// Search for the server
|
await searchPage.joinServerFromSearch(SERVER_NAME);
|
||||||
await searchPage.searchInput.fill(SERVER_NAME);
|
|
||||||
|
|
||||||
// Wait for search results and click the server
|
|
||||||
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
|
|
||||||
|
|
||||||
await expect(serverCard).toBeVisible({ timeout: 10_000 });
|
|
||||||
await serverCard.click();
|
|
||||||
|
|
||||||
// Bob should be in the room now
|
// Bob should be in the room now
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
@@ -146,8 +154,38 @@ test.describe('Full user journey: register → server → voice chat', () => {
|
|||||||
// ── Step 7: Verify audio is flowing in both directions ───────────
|
// ── Step 7: Verify audio is flowing in both directions ───────────
|
||||||
|
|
||||||
await test.step('Audio packets are flowing between Alice and Bob', async () => {
|
await test.step('Audio packets are flowing between Alice and Bob', async () => {
|
||||||
const aliceDelta = await waitForAudioFlow(alice.page, 30_000);
|
// Chromium's --use-fake-device-for-media-stream can produce a
|
||||||
const bobDelta = await waitForAudioFlow(bob.page, 30_000);
|
// 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
|
if (aliceDelta.outboundBytesDelta === 0 || aliceDelta.inboundBytesDelta === 0
|
||||||
|| bobDelta.outboundBytesDelta === 0 || bobDelta.inboundBytesDelta === 0) {
|
|| bobDelta.outboundBytesDelta === 0 || bobDelta.inboundBytesDelta === 0) {
|
||||||
|
|||||||
32
electron/README.md
Normal file
32
electron/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 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.
|
||||||
|
- Treat `dist/electron/` and `dist-electron/` as generated output.
|
||||||
|
- See [AGENTS.md](AGENTS.md) for package-level editing rules.
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
setupSystemHandlers,
|
setupSystemHandlers,
|
||||||
setupWindowControlHandlers
|
setupWindowControlHandlers
|
||||||
} from '../ipc';
|
} from '../ipc';
|
||||||
|
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
||||||
|
|
||||||
export function registerAppLifecycle(): void {
|
export function registerAppLifecycle(): void {
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
@@ -34,6 +35,7 @@ export function registerAppLifecycle(): void {
|
|||||||
await synchronizeAutoStartSetting();
|
await synchronizeAutoStartSetting();
|
||||||
initializeDesktopUpdater();
|
initializeDesktopUpdater();
|
||||||
await createWindow();
|
await createWindow();
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (getMainWindow()) {
|
if (getMainWindow()) {
|
||||||
@@ -57,6 +59,7 @@ export function registerAppLifecycle(): void {
|
|||||||
if (getDataSource()?.isInitialized) {
|
if (getDataSource()?.isInitialized) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
shutdownDesktopUpdater();
|
shutdownDesktopUpdater();
|
||||||
|
stopIdleMonitor();
|
||||||
await cleanupLinuxScreenShareAudioRouting();
|
await cleanupLinuxScreenShareAudioRouting();
|
||||||
await destroyDatabase();
|
await destroyDatabase();
|
||||||
app.quit();
|
app.quit();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ export function rowToUser(row: UserEntity) {
|
|||||||
oderId: row.oderId ?? '',
|
oderId: row.oderId ?? '',
|
||||||
username: row.username ?? '',
|
username: row.username ?? '',
|
||||||
displayName: row.displayName ?? '',
|
displayName: row.displayName ?? '',
|
||||||
|
description: row.description ?? undefined,
|
||||||
|
profileUpdatedAt: row.profileUpdatedAt ?? undefined,
|
||||||
avatarUrl: row.avatarUrl ?? undefined,
|
avatarUrl: row.avatarUrl ?? undefined,
|
||||||
|
avatarHash: row.avatarHash ?? undefined,
|
||||||
|
avatarMime: row.avatarMime ?? undefined,
|
||||||
|
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
|
||||||
status: row.status ?? 'offline',
|
status: row.status ?? 'offline',
|
||||||
role: row.role ?? 'member',
|
role: row.role ?? 'member',
|
||||||
joinedAt: row.joinedAt ?? 0,
|
joinedAt: row.joinedAt ?? 0,
|
||||||
|
|||||||
9
electron/cqrs/queries/handlers/getCurrentUserId.ts
Normal file
9
electron/cqrs/queries/handlers/getCurrentUserId.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { MetaEntity } from '../../../entities';
|
||||||
|
|
||||||
|
export async function handleGetCurrentUserId(dataSource: DataSource): Promise<string | null> {
|
||||||
|
const metaRepo = dataSource.getRepository(MetaEntity);
|
||||||
|
const metaRow = await metaRepo.findOne({ where: { key: 'currentUserId' } });
|
||||||
|
|
||||||
|
return metaRow?.value?.trim() || null;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
GetMessageByIdQuery,
|
GetMessageByIdQuery,
|
||||||
GetReactionsForMessageQuery,
|
GetReactionsForMessageQuery,
|
||||||
GetUserQuery,
|
GetUserQuery,
|
||||||
|
GetCurrentUserIdQuery,
|
||||||
GetRoomQuery,
|
GetRoomQuery,
|
||||||
GetBansForRoomQuery,
|
GetBansForRoomQuery,
|
||||||
IsUserBannedQuery,
|
IsUserBannedQuery,
|
||||||
@@ -19,6 +20,7 @@ import { handleGetMessageById } from './handlers/getMessageById';
|
|||||||
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
|
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
|
||||||
import { handleGetUser } from './handlers/getUser';
|
import { handleGetUser } from './handlers/getUser';
|
||||||
import { handleGetCurrentUser } from './handlers/getCurrentUser';
|
import { handleGetCurrentUser } from './handlers/getCurrentUser';
|
||||||
|
import { handleGetCurrentUserId } from './handlers/getCurrentUserId';
|
||||||
import { handleGetUsersByRoom } from './handlers/getUsersByRoom';
|
import { handleGetUsersByRoom } from './handlers/getUsersByRoom';
|
||||||
import { handleGetRoom } from './handlers/getRoom';
|
import { handleGetRoom } from './handlers/getRoom';
|
||||||
import { handleGetAllRooms } from './handlers/getAllRooms';
|
import { handleGetAllRooms } from './handlers/getAllRooms';
|
||||||
@@ -34,6 +36,7 @@ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey,
|
|||||||
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
|
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
|
||||||
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
|
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
|
||||||
[QueryType.GetCurrentUser]: () => handleGetCurrentUser(dataSource),
|
[QueryType.GetCurrentUser]: () => handleGetCurrentUser(dataSource),
|
||||||
|
[QueryType.GetCurrentUserId]: () => handleGetCurrentUserId(dataSource),
|
||||||
[QueryType.GetUsersByRoom]: () => handleGetUsersByRoom(dataSource),
|
[QueryType.GetUsersByRoom]: () => handleGetUsersByRoom(dataSource),
|
||||||
[QueryType.GetRoom]: (query) => handleGetRoom(query as GetRoomQuery, dataSource),
|
[QueryType.GetRoom]: (query) => handleGetRoom(query as GetRoomQuery, dataSource),
|
||||||
[QueryType.GetAllRooms]: () => handleGetAllRooms(dataSource),
|
[QueryType.GetAllRooms]: () => handleGetAllRooms(dataSource),
|
||||||
|
|||||||
@@ -66,7 +66,12 @@ export interface RoomMemberRecord {
|
|||||||
oderId?: string;
|
oderId?: string;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
avatarHash?: string;
|
||||||
|
avatarMime?: string;
|
||||||
|
avatarUpdatedAt?: number;
|
||||||
role: RoomMemberRole;
|
role: RoomMemberRole;
|
||||||
roleIds?: string[];
|
roleIds?: string[];
|
||||||
joinedAt: number;
|
joinedAt: number;
|
||||||
@@ -335,19 +340,33 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
|
|||||||
const { joinedAt, lastSeenAt } = resolveRoomMemberTimes(rawMember, now);
|
const { joinedAt, lastSeenAt } = resolveRoomMemberTimes(rawMember, now);
|
||||||
const username = trimmedString(rawMember, 'username');
|
const username = trimmedString(rawMember, 'username');
|
||||||
const displayName = trimmedString(rawMember, 'displayName');
|
const displayName = trimmedString(rawMember, 'displayName');
|
||||||
|
const description = trimmedString(rawMember, 'description');
|
||||||
|
const profileUpdatedAt = isFiniteNumber(rawMember['profileUpdatedAt']) ? rawMember['profileUpdatedAt'] : undefined;
|
||||||
const avatarUrl = trimmedString(rawMember, 'avatarUrl');
|
const avatarUrl = trimmedString(rawMember, 'avatarUrl');
|
||||||
|
const avatarHash = trimmedString(rawMember, 'avatarHash');
|
||||||
return {
|
const avatarMime = trimmedString(rawMember, 'avatarMime');
|
||||||
|
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
|
||||||
|
const member: RoomMemberRecord = {
|
||||||
id: normalizedId || normalizedKey,
|
id: normalizedId || normalizedKey,
|
||||||
oderId: normalizedOderId || undefined,
|
oderId: normalizedOderId || undefined,
|
||||||
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
|
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
|
||||||
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
|
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
|
||||||
|
profileUpdatedAt,
|
||||||
avatarUrl: avatarUrl || undefined,
|
avatarUrl: avatarUrl || undefined,
|
||||||
|
avatarHash: avatarHash || undefined,
|
||||||
|
avatarMime: avatarMime || undefined,
|
||||||
|
avatarUpdatedAt,
|
||||||
role: normalizeRoomMemberRole(rawMember['role']),
|
role: normalizeRoomMemberRole(rawMember['role']),
|
||||||
roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined),
|
roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined),
|
||||||
joinedAt,
|
joinedAt,
|
||||||
lastSeenAt
|
lastSeenAt
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(rawMember, 'description')) {
|
||||||
|
member.description = description || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return member;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord {
|
function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord {
|
||||||
@@ -356,6 +375,16 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
|
|||||||
}
|
}
|
||||||
|
|
||||||
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
|
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
|
||||||
|
const existingProfileUpdatedAt = existingMember.profileUpdatedAt ?? 0;
|
||||||
|
const incomingProfileUpdatedAt = incomingMember.profileUpdatedAt ?? 0;
|
||||||
|
const preferIncomingProfile = incomingProfileUpdatedAt === existingProfileUpdatedAt
|
||||||
|
? preferIncoming
|
||||||
|
: incomingProfileUpdatedAt > existingProfileUpdatedAt;
|
||||||
|
const existingAvatarUpdatedAt = existingMember.avatarUpdatedAt ?? 0;
|
||||||
|
const incomingAvatarUpdatedAt = incomingMember.avatarUpdatedAt ?? 0;
|
||||||
|
const preferIncomingAvatar = incomingAvatarUpdatedAt === existingAvatarUpdatedAt
|
||||||
|
? preferIncoming
|
||||||
|
: incomingAvatarUpdatedAt > existingAvatarUpdatedAt;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: existingMember.id || incomingMember.id,
|
id: existingMember.id || incomingMember.id,
|
||||||
@@ -363,12 +392,23 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
|
|||||||
username: preferIncoming
|
username: preferIncoming
|
||||||
? (incomingMember.username || existingMember.username)
|
? (incomingMember.username || existingMember.username)
|
||||||
: (existingMember.username || incomingMember.username),
|
: (existingMember.username || incomingMember.username),
|
||||||
displayName: preferIncoming
|
displayName: preferIncomingProfile
|
||||||
? (incomingMember.displayName || existingMember.displayName)
|
? (incomingMember.displayName || existingMember.displayName)
|
||||||
: (existingMember.displayName || incomingMember.displayName),
|
: (existingMember.displayName || incomingMember.displayName),
|
||||||
avatarUrl: preferIncoming
|
description: preferIncomingProfile
|
||||||
|
? (Object.prototype.hasOwnProperty.call(incomingMember, 'description') ? incomingMember.description : existingMember.description)
|
||||||
|
: existingMember.description,
|
||||||
|
profileUpdatedAt: Math.max(existingProfileUpdatedAt, incomingProfileUpdatedAt) || undefined,
|
||||||
|
avatarUrl: preferIncomingAvatar
|
||||||
? (incomingMember.avatarUrl || existingMember.avatarUrl)
|
? (incomingMember.avatarUrl || existingMember.avatarUrl)
|
||||||
: (existingMember.avatarUrl || incomingMember.avatarUrl),
|
: (existingMember.avatarUrl || incomingMember.avatarUrl),
|
||||||
|
avatarHash: preferIncomingAvatar
|
||||||
|
? (incomingMember.avatarHash || existingMember.avatarHash)
|
||||||
|
: (existingMember.avatarHash || incomingMember.avatarHash),
|
||||||
|
avatarMime: preferIncomingAvatar
|
||||||
|
? (incomingMember.avatarMime || existingMember.avatarMime)
|
||||||
|
: (existingMember.avatarMime || incomingMember.avatarMime),
|
||||||
|
avatarUpdatedAt: Math.max(existingAvatarUpdatedAt, incomingAvatarUpdatedAt) || undefined,
|
||||||
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
|
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
|
||||||
roleIds: preferIncoming
|
roleIds: preferIncoming
|
||||||
? (incomingMember.roleIds || existingMember.roleIds)
|
? (incomingMember.roleIds || existingMember.roleIds)
|
||||||
@@ -759,7 +799,12 @@ export async function replaceRoomRelations(
|
|||||||
oderId: member.oderId ?? null,
|
oderId: member.oderId ?? null,
|
||||||
username: member.username,
|
username: member.username,
|
||||||
displayName: member.displayName,
|
displayName: member.displayName,
|
||||||
|
description: member.description ?? null,
|
||||||
|
profileUpdatedAt: member.profileUpdatedAt ?? null,
|
||||||
avatarUrl: member.avatarUrl ?? null,
|
avatarUrl: member.avatarUrl ?? null,
|
||||||
|
avatarHash: member.avatarHash ?? null,
|
||||||
|
avatarMime: member.avatarMime ?? null,
|
||||||
|
avatarUpdatedAt: member.avatarUpdatedAt ?? null,
|
||||||
role: member.role,
|
role: member.role,
|
||||||
joinedAt: member.joinedAt,
|
joinedAt: member.joinedAt,
|
||||||
lastSeenAt: member.lastSeenAt
|
lastSeenAt: member.lastSeenAt
|
||||||
@@ -906,7 +951,12 @@ export async function loadRoomRelationsMap(
|
|||||||
oderId: row.oderId ?? undefined,
|
oderId: row.oderId ?? undefined,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
|
description: row.description ?? undefined,
|
||||||
|
profileUpdatedAt: row.profileUpdatedAt ?? undefined,
|
||||||
avatarUrl: row.avatarUrl ?? undefined,
|
avatarUrl: row.avatarUrl ?? undefined,
|
||||||
|
avatarHash: row.avatarHash ?? undefined,
|
||||||
|
avatarMime: row.avatarMime ?? undefined,
|
||||||
|
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
joinedAt: row.joinedAt,
|
joinedAt: row.joinedAt,
|
||||||
lastSeenAt: row.lastSeenAt
|
lastSeenAt: row.lastSeenAt
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const QueryType = {
|
|||||||
GetReactionsForMessage: 'get-reactions-for-message',
|
GetReactionsForMessage: 'get-reactions-for-message',
|
||||||
GetUser: 'get-user',
|
GetUser: 'get-user',
|
||||||
GetCurrentUser: 'get-current-user',
|
GetCurrentUser: 'get-current-user',
|
||||||
|
GetCurrentUserId: 'get-current-user-id',
|
||||||
GetUsersByRoom: 'get-users-by-room',
|
GetUsersByRoom: 'get-users-by-room',
|
||||||
GetRoom: 'get-room',
|
GetRoom: 'get-room',
|
||||||
GetAllRooms: 'get-all-rooms',
|
GetAllRooms: 'get-all-rooms',
|
||||||
@@ -105,7 +106,12 @@ export interface UserPayload {
|
|||||||
oderId?: string;
|
oderId?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
avatarHash?: string;
|
||||||
|
avatarMime?: string;
|
||||||
|
avatarUpdatedAt?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
joinedAt?: number;
|
joinedAt?: number;
|
||||||
@@ -209,6 +215,7 @@ export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; pa
|
|||||||
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
|
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
|
||||||
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
|
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
|
||||||
export interface GetCurrentUserQuery { type: typeof QueryType.GetCurrentUser; payload: Record<string, never> }
|
export interface GetCurrentUserQuery { type: typeof QueryType.GetCurrentUser; payload: Record<string, never> }
|
||||||
|
export interface GetCurrentUserIdQuery { type: typeof QueryType.GetCurrentUserId; payload: Record<string, never> }
|
||||||
export interface GetUsersByRoomQuery { type: typeof QueryType.GetUsersByRoom; payload: { roomId: string } }
|
export interface GetUsersByRoomQuery { type: typeof QueryType.GetUsersByRoom; payload: { roomId: string } }
|
||||||
export interface GetRoomQuery { type: typeof QueryType.GetRoom; payload: { roomId: string } }
|
export interface GetRoomQuery { type: typeof QueryType.GetRoom; payload: { roomId: string } }
|
||||||
export interface GetAllRoomsQuery { type: typeof QueryType.GetAllRooms; payload: Record<string, never> }
|
export interface GetAllRoomsQuery { type: typeof QueryType.GetAllRooms; payload: Record<string, never> }
|
||||||
@@ -224,6 +231,7 @@ export type Query =
|
|||||||
| GetReactionsForMessageQuery
|
| GetReactionsForMessageQuery
|
||||||
| GetUserQuery
|
| GetUserQuery
|
||||||
| GetCurrentUserQuery
|
| GetCurrentUserQuery
|
||||||
|
| GetCurrentUserIdQuery
|
||||||
| GetUsersByRoomQuery
|
| GetUsersByRoomQuery
|
||||||
| GetRoomQuery
|
| GetRoomQuery
|
||||||
| GetAllRoomsQuery
|
| GetAllRoomsQuery
|
||||||
|
|||||||
229
electron/data-archive.ts
Normal file
229
electron/data-archive.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export interface ZipArchiveEntry {
|
||||||
|
data: Buffer;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CentralDirectoryEntry {
|
||||||
|
compressedSize: number;
|
||||||
|
crc: number;
|
||||||
|
data: Buffer;
|
||||||
|
localHeaderOffset: number;
|
||||||
|
name: Buffer;
|
||||||
|
uncompressedSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||||
|
const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
||||||
|
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
||||||
|
const ZIP_UTF8_FLAG = 0x0800;
|
||||||
|
const ZIP_STORE_METHOD = 0;
|
||||||
|
const ZIP_VERSION = 20;
|
||||||
|
const MAX_UINT32 = 0xffffffff;
|
||||||
|
|
||||||
|
const crcTable = buildCrcTable();
|
||||||
|
|
||||||
|
export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
|
||||||
|
const localParts: Buffer[] = [];
|
||||||
|
const centralEntries: CentralDirectoryEntry[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const normalizedPath = normalizeZipPath(entry.path);
|
||||||
|
const name = Buffer.from(normalizedPath, 'utf8');
|
||||||
|
const data = entry.data;
|
||||||
|
|
||||||
|
if (name.length > 0xffff || data.length > MAX_UINT32 || offset > MAX_UINT32) {
|
||||||
|
throw new Error('Data archive is too large for the portable ZIP format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const crc = crc32(data);
|
||||||
|
const localHeader = Buffer.alloc(30);
|
||||||
|
|
||||||
|
localHeader.writeUInt32LE(ZIP_LOCAL_FILE_HEADER_SIGNATURE, 0);
|
||||||
|
localHeader.writeUInt16LE(ZIP_VERSION, 4);
|
||||||
|
localHeader.writeUInt16LE(ZIP_UTF8_FLAG, 6);
|
||||||
|
localHeader.writeUInt16LE(ZIP_STORE_METHOD, 8);
|
||||||
|
localHeader.writeUInt16LE(0, 10);
|
||||||
|
localHeader.writeUInt16LE(0, 12);
|
||||||
|
localHeader.writeUInt32LE(crc, 14);
|
||||||
|
localHeader.writeUInt32LE(data.length, 18);
|
||||||
|
localHeader.writeUInt32LE(data.length, 22);
|
||||||
|
localHeader.writeUInt16LE(name.length, 26);
|
||||||
|
localHeader.writeUInt16LE(0, 28);
|
||||||
|
|
||||||
|
localParts.push(localHeader, name, data);
|
||||||
|
centralEntries.push({
|
||||||
|
compressedSize: data.length,
|
||||||
|
crc,
|
||||||
|
data,
|
||||||
|
localHeaderOffset: offset,
|
||||||
|
name,
|
||||||
|
uncompressedSize: data.length
|
||||||
|
});
|
||||||
|
|
||||||
|
offset += localHeader.length + name.length + data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralDirectoryOffset = offset;
|
||||||
|
const centralParts = centralEntries.map((entry) => {
|
||||||
|
const header = Buffer.alloc(46);
|
||||||
|
|
||||||
|
header.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_SIGNATURE, 0);
|
||||||
|
header.writeUInt16LE(ZIP_VERSION, 4);
|
||||||
|
header.writeUInt16LE(ZIP_VERSION, 6);
|
||||||
|
header.writeUInt16LE(ZIP_UTF8_FLAG, 8);
|
||||||
|
header.writeUInt16LE(ZIP_STORE_METHOD, 10);
|
||||||
|
header.writeUInt16LE(0, 12);
|
||||||
|
header.writeUInt16LE(0, 14);
|
||||||
|
header.writeUInt32LE(entry.crc, 16);
|
||||||
|
header.writeUInt32LE(entry.compressedSize, 20);
|
||||||
|
header.writeUInt32LE(entry.uncompressedSize, 24);
|
||||||
|
header.writeUInt16LE(entry.name.length, 28);
|
||||||
|
header.writeUInt16LE(0, 30);
|
||||||
|
header.writeUInt16LE(0, 32);
|
||||||
|
header.writeUInt16LE(0, 34);
|
||||||
|
header.writeUInt16LE(0, 36);
|
||||||
|
header.writeUInt32LE(0, 38);
|
||||||
|
header.writeUInt32LE(entry.localHeaderOffset, 42);
|
||||||
|
|
||||||
|
offset += header.length + entry.name.length;
|
||||||
|
|
||||||
|
return Buffer.concat([header, entry.name]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const centralDirectorySize = offset - centralDirectoryOffset;
|
||||||
|
|
||||||
|
if (centralEntries.length > 0xffff || centralDirectoryOffset > MAX_UINT32 || centralDirectorySize > MAX_UINT32) {
|
||||||
|
throw new Error('Data archive is too large for the portable ZIP format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = Buffer.alloc(22);
|
||||||
|
|
||||||
|
end.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE, 0);
|
||||||
|
end.writeUInt16LE(0, 4);
|
||||||
|
end.writeUInt16LE(0, 6);
|
||||||
|
end.writeUInt16LE(centralEntries.length, 8);
|
||||||
|
end.writeUInt16LE(centralEntries.length, 10);
|
||||||
|
end.writeUInt32LE(centralDirectorySize, 12);
|
||||||
|
end.writeUInt32LE(centralDirectoryOffset, 16);
|
||||||
|
end.writeUInt16LE(0, 20);
|
||||||
|
|
||||||
|
return Buffer.concat([...localParts, ...centralParts, end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
|
||||||
|
const endOffset = findEndOfCentralDirectory(data);
|
||||||
|
|
||||||
|
if (endOffset < 0) {
|
||||||
|
throw new Error('The selected file is not a supported data archive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryCount = data.readUInt16LE(endOffset + 10);
|
||||||
|
const centralDirectoryOffset = data.readUInt32LE(endOffset + 16);
|
||||||
|
const entries: ZipArchiveEntry[] = [];
|
||||||
|
let offset = centralDirectoryOffset;
|
||||||
|
|
||||||
|
for (let index = 0; index < entryCount; index += 1) {
|
||||||
|
if (data.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) {
|
||||||
|
throw new Error('The data archive directory is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = data.readUInt16LE(offset + 10);
|
||||||
|
const compressedSize = data.readUInt32LE(offset + 20);
|
||||||
|
const uncompressedSize = data.readUInt32LE(offset + 24);
|
||||||
|
const nameLength = data.readUInt16LE(offset + 28);
|
||||||
|
const extraLength = data.readUInt16LE(offset + 30);
|
||||||
|
const commentLength = data.readUInt16LE(offset + 32);
|
||||||
|
const localHeaderOffset = data.readUInt32LE(offset + 42);
|
||||||
|
const entryPath = normalizeZipPath(data.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'));
|
||||||
|
|
||||||
|
if (method !== ZIP_STORE_METHOD || compressedSize !== uncompressedSize) {
|
||||||
|
throw new Error('Compressed data archives are not supported by this build.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
|
||||||
|
throw new Error('The data archive contains an invalid file entry.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const localNameLength = data.readUInt16LE(localHeaderOffset + 26);
|
||||||
|
const localExtraLength = data.readUInt16LE(localHeaderOffset + 28);
|
||||||
|
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength;
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
data: Buffer.from(data.subarray(dataOffset, dataOffset + compressedSize)),
|
||||||
|
path: entryPath
|
||||||
|
});
|
||||||
|
|
||||||
|
offset += 46 + nameLength + extraLength + commentLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractZipEntries(entries: ZipArchiveEntry[], destinationPath: string): Promise<void> {
|
||||||
|
const destinationRoot = path.resolve(destinationPath);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const targetPath = path.resolve(destinationRoot, entry.path);
|
||||||
|
|
||||||
|
if (!targetPath.startsWith(destinationRoot + path.sep) && targetPath !== destinationRoot) {
|
||||||
|
throw new Error('The data archive contains an unsafe path.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fsp.writeFile(targetPath, entry.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEndOfCentralDirectory(data: Buffer): number {
|
||||||
|
const minimumOffset = Math.max(0, data.length - 0xffff - 22);
|
||||||
|
|
||||||
|
for (let offset = data.length - 22; offset >= minimumOffset; offset -= 1) {
|
||||||
|
if (data.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeZipPath(value: string): string {
|
||||||
|
const normalized = value.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||||
|
|
||||||
|
if (!normalized || normalized.split('/').some((part) => part === '..' || part === '')) {
|
||||||
|
throw new Error('The data archive contains an unsafe path.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCrcTable(): number[] {
|
||||||
|
const table: number[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < 256; index += 1) {
|
||||||
|
let value = index;
|
||||||
|
|
||||||
|
for (let bit = 0; bit < 8; bit += 1) {
|
||||||
|
value = (value & 1) !== 0
|
||||||
|
? 0xedb88320 ^ (value >>> 1)
|
||||||
|
: value >>> 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
table[index] = value >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crc32(data: Buffer): number {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
|
||||||
|
for (const byte of data) {
|
||||||
|
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
257
electron/data-management.ts
Normal file
257
electron/data-management.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import {
|
||||||
|
app,
|
||||||
|
dialog,
|
||||||
|
shell
|
||||||
|
} from 'electron';
|
||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { destroyDatabase, initializeDatabase } from './db/database';
|
||||||
|
import {
|
||||||
|
createZipArchive,
|
||||||
|
extractZipEntries,
|
||||||
|
readZipArchive,
|
||||||
|
type ZipArchiveEntry
|
||||||
|
} from './data-archive';
|
||||||
|
|
||||||
|
export interface ExportUserDataResult {
|
||||||
|
cancelled: boolean;
|
||||||
|
exported: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportUserDataResult {
|
||||||
|
backupPath?: string;
|
||||||
|
cancelled: boolean;
|
||||||
|
imported: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EraseUserDataResult {
|
||||||
|
erased: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARCHIVE_MANIFEST_PATH = 'metoyou-data-manifest.json';
|
||||||
|
const ARCHIVE_DATA_PREFIX = 'data/';
|
||||||
|
const BACKUP_DIRECTORY_NAME = 'metoyou-data-backups';
|
||||||
|
|
||||||
|
export async function openCurrentDataFolder(): Promise<boolean> {
|
||||||
|
const error = await shell.openPath(app.getPath('userData'));
|
||||||
|
|
||||||
|
return error.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportUserData(): Promise<ExportUserDataResult> {
|
||||||
|
const dataPath = app.getPath('userData');
|
||||||
|
const defaultFileName = `metoyou-data-${new Date().toISOString().slice(0, 10)}.dat`;
|
||||||
|
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||||
|
defaultPath: path.join(app.getPath('documents'), defaultFileName),
|
||||||
|
filters: [
|
||||||
|
{ extensions: ['dat'], name: 'MetoYou data archive' }
|
||||||
|
],
|
||||||
|
title: 'Export MetoYou data'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (canceled || !filePath) {
|
||||||
|
return { cancelled: true, exported: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: ZipArchiveEntry[] = [
|
||||||
|
{
|
||||||
|
data: Buffer.from(JSON.stringify({
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
format: 'metoyou-user-data',
|
||||||
|
version: 1
|
||||||
|
}, null, 2)),
|
||||||
|
path: ARCHIVE_MANIFEST_PATH
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of await collectDataFiles(dataPath)) {
|
||||||
|
const relativePath = toArchivePath(path.relative(dataPath, file));
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
data: await fsp.readFile(file),
|
||||||
|
path: `${ARCHIVE_DATA_PREFIX}${relativePath}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.writeFile(ensureDatExtension(filePath), createZipArchive(entries));
|
||||||
|
|
||||||
|
return {
|
||||||
|
cancelled: false,
|
||||||
|
exported: true,
|
||||||
|
filePath: ensureDatExtension(filePath)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importUserData(): Promise<ImportUserDataResult> {
|
||||||
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||||
|
filters: [
|
||||||
|
{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }
|
||||||
|
],
|
||||||
|
properties: ['openFile'],
|
||||||
|
title: 'Import MetoYou data'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (canceled || filePaths.length === 0) {
|
||||||
|
return {
|
||||||
|
cancelled: true,
|
||||||
|
imported: false,
|
||||||
|
restartRequired: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveEntries = readZipArchive(await fsp.readFile(filePaths[0]));
|
||||||
|
|
||||||
|
validateArchiveManifest(archiveEntries);
|
||||||
|
|
||||||
|
const importRoot = path.join(app.getPath('temp'), `metoyou-import-${Date.now()}`);
|
||||||
|
const importDataPath = path.join(importRoot, 'data');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await extractZipEntries(
|
||||||
|
archiveEntries
|
||||||
|
.filter((entry) => entry.path.startsWith(ARCHIVE_DATA_PREFIX))
|
||||||
|
.map((entry) => ({
|
||||||
|
data: entry.data,
|
||||||
|
path: entry.path.slice(ARCHIVE_DATA_PREFIX.length)
|
||||||
|
})),
|
||||||
|
importDataPath
|
||||||
|
);
|
||||||
|
|
||||||
|
await destroyDatabase();
|
||||||
|
|
||||||
|
const backupPath = await moveCurrentDataAside();
|
||||||
|
|
||||||
|
await copyDirectory(importDataPath, app.getPath('userData'));
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
backupPath,
|
||||||
|
cancelled: false,
|
||||||
|
imported: true,
|
||||||
|
restartRequired: true
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await initializeDatabase().catch(() => {});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await fsp.rm(importRoot, { force: true, recursive: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eraseUserData(): Promise<EraseUserDataResult> {
|
||||||
|
const dataPath = app.getPath('userData');
|
||||||
|
|
||||||
|
await destroyDatabase();
|
||||||
|
|
||||||
|
for (const entry of await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => [])) {
|
||||||
|
await fsp.rm(path.join(dataPath, entry.name), { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.mkdir(dataPath, { recursive: true });
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
erased: true,
|
||||||
|
restartRequired: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectDataFiles(directoryPath: string): Promise<string[]> {
|
||||||
|
const files: string[] = [];
|
||||||
|
const entries = await fsp.readdir(directoryPath, { withFileTypes: true }).catch(() => []);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === BACKUP_DIRECTORY_NAME) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPath = path.join(directoryPath, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...await collectDataFiles(entryPath));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
files.push(entryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveCurrentDataAside(): Promise<string | undefined> {
|
||||||
|
const dataPath = app.getPath('userData');
|
||||||
|
const backupRoot = path.join(dataPath, BACKUP_DIRECTORY_NAME);
|
||||||
|
const backupPath = path.join(backupRoot, `before-import-${new Date().toISOString().replace(/[:.]/g, '-')}`);
|
||||||
|
const entries = await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => []);
|
||||||
|
|
||||||
|
await fsp.mkdir(backupPath, { recursive: true });
|
||||||
|
|
||||||
|
let movedAny = false;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === BACKUP_DIRECTORY_NAME) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = path.join(dataPath, entry.name);
|
||||||
|
const targetPath = path.join(backupPath, entry.name);
|
||||||
|
|
||||||
|
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fsp.rename(sourcePath, targetPath).catch(async () => {
|
||||||
|
await copyPath(sourcePath, targetPath);
|
||||||
|
await fsp.rm(sourcePath, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
movedAny = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return movedAny ? backupPath : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyDirectory(sourcePath: string, targetPath: string): Promise<void> {
|
||||||
|
await fsp.mkdir(targetPath, { recursive: true });
|
||||||
|
|
||||||
|
for (const entry of await fsp.readdir(sourcePath, { withFileTypes: true }).catch(() => [])) {
|
||||||
|
await copyPath(path.join(sourcePath, entry.name), path.join(targetPath, entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPath(sourcePath: string, targetPath: string): Promise<void> {
|
||||||
|
const stats = await fsp.stat(sourcePath);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
await copyDirectory(sourcePath, targetPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.isFile()) {
|
||||||
|
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fsp.copyFile(sourcePath, targetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateArchiveManifest(entries: ZipArchiveEntry[]): void {
|
||||||
|
const manifest = entries.find((entry) => entry.path === ARCHIVE_MANIFEST_PATH);
|
||||||
|
|
||||||
|
if (!manifest) {
|
||||||
|
throw new Error('The selected file is missing a MetoYou data manifest.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(manifest.data.toString('utf8')) as { format?: string; version?: number };
|
||||||
|
|
||||||
|
if (parsed.format !== 'metoyou-user-data' || parsed.version !== 1) {
|
||||||
|
throw new Error('The selected file uses an unsupported data archive format.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDatExtension(filePath: string): string {
|
||||||
|
return path.extname(filePath).toLowerCase() === '.dat'
|
||||||
|
? filePath
|
||||||
|
: `${filePath}.dat`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArchivePath(filePath: string): string {
|
||||||
|
return filePath.split(path.sep).join('/');
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as fsp from 'fs/promises';
|
import * as fsp from 'fs/promises';
|
||||||
@@ -20,23 +21,93 @@ import {
|
|||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
|
|
||||||
let applicationDataSource: DataSource | undefined;
|
let applicationDataSource: DataSource | undefined;
|
||||||
|
let dbFilePath = '';
|
||||||
|
let dbBackupPath = '';
|
||||||
|
|
||||||
|
// SQLite files start with this 16-byte header string.
|
||||||
|
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||||
|
|
||||||
export function getDataSource(): DataSource | undefined {
|
export function getDataSource(): DataSource | undefined {
|
||||||
return applicationDataSource;
|
return applicationDataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when `data` looks like a valid SQLite file
|
||||||
|
* (correct header magic and at least one complete page).
|
||||||
|
*/
|
||||||
|
function isValidSqlite(data: Uint8Array): boolean {
|
||||||
|
if (data.length < 100)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const header = Buffer.from(data.buffer, data.byteOffset, 16).toString('ascii');
|
||||||
|
|
||||||
|
return header === SQLITE_MAGIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back up the current DB file so there is always a recovery point.
|
||||||
|
* If the main file is corrupted/empty but a valid backup exists,
|
||||||
|
* restore the backup before the app loads the database.
|
||||||
|
*/
|
||||||
|
function safeguardDbFile(): Uint8Array | undefined {
|
||||||
|
if (!fs.existsSync(dbFilePath))
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const data = new Uint8Array(fs.readFileSync(dbFilePath));
|
||||||
|
|
||||||
|
if (isValidSqlite(data)) {
|
||||||
|
fs.copyFileSync(dbFilePath, dbBackupPath);
|
||||||
|
console.log('[DB] Backed up database to', dbBackupPath);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[DB] ${dbFilePath} appears corrupt (${data.length} bytes) - checking backup`);
|
||||||
|
|
||||||
|
if (fs.existsSync(dbBackupPath)) {
|
||||||
|
const backup = new Uint8Array(fs.readFileSync(dbBackupPath));
|
||||||
|
|
||||||
|
if (isValidSqlite(backup)) {
|
||||||
|
fs.copyFileSync(dbBackupPath, dbFilePath);
|
||||||
|
console.warn('[DB] Restored database from backup', dbBackupPath);
|
||||||
|
|
||||||
|
return backup;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[DB] Backup is also invalid - starting with a fresh database');
|
||||||
|
} else {
|
||||||
|
console.error('[DB] No backup available - starting with a fresh database');
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the database to disk atomically: write a temp file first,
|
||||||
|
* then rename it over the real file. rename() is atomic on the same
|
||||||
|
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||||
|
*/
|
||||||
|
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||||
|
const tmpPath = dbFilePath + '.tmp-' + randomBytes(6).toString('hex');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fsp.writeFile(tmpPath, Buffer.from(data));
|
||||||
|
await fsp.rename(tmpPath, dbFilePath);
|
||||||
|
} catch (err) {
|
||||||
|
await fsp.unlink(tmpPath).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function initializeDatabase(): Promise<void> {
|
export async function initializeDatabase(): Promise<void> {
|
||||||
const userDataPath = app.getPath('userData');
|
const userDataPath = app.getPath('userData');
|
||||||
const dbDir = path.join(userDataPath, 'metoyou');
|
const dbDir = path.join(userDataPath, 'metoyou');
|
||||||
|
|
||||||
await fsp.mkdir(dbDir, { recursive: true });
|
await fsp.mkdir(dbDir, { recursive: true });
|
||||||
const databaseFilePath = path.join(dbDir, settings.databaseName);
|
dbFilePath = path.join(dbDir, settings.databaseName);
|
||||||
|
dbBackupPath = dbFilePath + '.bak';
|
||||||
|
|
||||||
let database: Uint8Array | undefined;
|
const database = safeguardDbFile();
|
||||||
|
|
||||||
if (fs.existsSync(databaseFilePath)) {
|
|
||||||
database = fs.readFileSync(databaseFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
applicationDataSource = new DataSource({
|
applicationDataSource = new DataSource({
|
||||||
type: 'sqljs',
|
type: 'sqljs',
|
||||||
@@ -59,12 +130,12 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging: false,
|
logging: false,
|
||||||
autoSave: true,
|
autoSave: true,
|
||||||
location: databaseFilePath
|
autoSaveCallback: atomicSave
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await applicationDataSource.initialize();
|
await applicationDataSource.initialize();
|
||||||
console.log('[DB] Connection initialised at:', databaseFilePath);
|
console.log('[DB] Connection initialised at:', dbFilePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await applicationDataSource.runMigrations();
|
await applicationDataSource.runMigrations();
|
||||||
|
|||||||
@@ -24,9 +24,24 @@ export class RoomMemberEntity {
|
|||||||
@Column('text')
|
@Column('text')
|
||||||
displayName!: string;
|
displayName!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
description!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
profileUpdatedAt!: number | null;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
avatarUrl!: string | null;
|
avatarUrl!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
avatarHash!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
avatarMime!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
avatarUpdatedAt!: number | null;
|
||||||
|
|
||||||
@Column('text')
|
@Column('text')
|
||||||
role!: 'host' | 'admin' | 'moderator' | 'member';
|
role!: 'host' | 'admin' | 'moderator' | 'member';
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,24 @@ export class UserEntity {
|
|||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
displayName!: string | null;
|
displayName!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
description!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
profileUpdatedAt!: number | null;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
avatarUrl!: string | null;
|
avatarUrl!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
avatarHash!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
avatarMime!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
avatarUpdatedAt!: number | null;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
status!: string | null;
|
status!: string | null;
|
||||||
|
|
||||||
|
|||||||
124
electron/idle/idle-monitor.spec.ts
Normal file
124
electron/idle/idle-monitor.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
vi,
|
||||||
|
beforeEach,
|
||||||
|
afterEach
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
// Mock Electron modules before importing the module under test
|
||||||
|
const mockGetSystemIdleTime = vi.fn(() => 0);
|
||||||
|
const mockSend = vi.fn();
|
||||||
|
const mockGetMainWindow = vi.fn(() => ({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
webContents: { send: mockSend }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
powerMonitor: {
|
||||||
|
getSystemIdleTime: mockGetSystemIdleTime
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../window/create-window', () => ({
|
||||||
|
getMainWindow: mockGetMainWindow
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
startIdleMonitor,
|
||||||
|
stopIdleMonitor,
|
||||||
|
getIdleState
|
||||||
|
} from './idle-monitor';
|
||||||
|
|
||||||
|
describe('idle-monitor', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(0);
|
||||||
|
mockSend.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stopIdleMonitor();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns active when idle time is below threshold', () => {
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(0);
|
||||||
|
expect(getIdleState()).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns idle when idle time exceeds 15 minutes', () => {
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
expect(getIdleState()).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends idle-state-changed to renderer when transitioning to idle', () => {
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends idle-state-changed to renderer when transitioning back to active', () => {
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
// Go idle
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
mockSend.mockClear();
|
||||||
|
|
||||||
|
// Go active
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(5);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire duplicates when state stays the same', () => {
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
// Only one transition, so only one call
|
||||||
|
const idleCalls = mockSend.mock.calls.filter(
|
||||||
|
([channel, state]: [string, string]) => channel === 'idle-state-changed' && state === 'idle'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(idleCalls.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops polling after stopIdleMonitor', () => {
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
mockSend.mockClear();
|
||||||
|
|
||||||
|
stopIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(0);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(mockSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not notify when main window is null', () => {
|
||||||
|
mockGetMainWindow.mockReturnValue(null);
|
||||||
|
startIdleMonitor();
|
||||||
|
|
||||||
|
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
|
||||||
|
expect(mockSend).not.toHaveBeenCalled();
|
||||||
|
mockGetMainWindow.mockReturnValue({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
webContents: { send: mockSend }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
49
electron/idle/idle-monitor.ts
Normal file
49
electron/idle/idle-monitor.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { powerMonitor } from 'electron';
|
||||||
|
import { getMainWindow } from '../window/create-window';
|
||||||
|
|
||||||
|
const IDLE_THRESHOLD_SECONDS = 15 * 60; // 15 minutes
|
||||||
|
const POLL_INTERVAL_MS = 10_000; // Check every 10 seconds
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let wasIdle = false;
|
||||||
|
|
||||||
|
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
|
||||||
|
|
||||||
|
export type IdleState = 'active' | 'idle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts polling `powerMonitor.getSystemIdleTime()` and notifies the
|
||||||
|
* renderer whenever the user transitions between active and idle.
|
||||||
|
*/
|
||||||
|
export function startIdleMonitor(): void {
|
||||||
|
if (pollTimer)
|
||||||
|
return;
|
||||||
|
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
const idleSeconds = powerMonitor.getSystemIdleTime();
|
||||||
|
const isIdle = idleSeconds >= IDLE_THRESHOLD_SECONDS;
|
||||||
|
|
||||||
|
if (isIdle !== wasIdle) {
|
||||||
|
wasIdle = isIdle;
|
||||||
|
const state: IdleState = isIdle ? 'idle' : 'active';
|
||||||
|
const mainWindow = getMainWindow();
|
||||||
|
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send(IDLE_STATE_CHANGED_CHANNEL, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopIdleMonitor(): void {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIdleState(): IdleState {
|
||||||
|
const idleSeconds = powerMonitor.getSystemIdleTime();
|
||||||
|
|
||||||
|
return idleSeconds >= IDLE_THRESHOLD_SECONDS ? 'idle' : 'active';
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from '../update/desktop-updater';
|
} from '../update/desktop-updater';
|
||||||
import { consumePendingDeepLink } from '../app/deep-links';
|
import { consumePendingDeepLink } from '../app/deep-links';
|
||||||
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
||||||
|
import { getIdleState } from '../idle/idle-monitor';
|
||||||
import {
|
import {
|
||||||
getMainWindow,
|
getMainWindow,
|
||||||
getWindowIconPath,
|
getWindowIconPath,
|
||||||
@@ -48,6 +49,13 @@ import {
|
|||||||
readSavedTheme,
|
readSavedTheme,
|
||||||
writeSavedTheme
|
writeSavedTheme
|
||||||
} from '../theme-library';
|
} from '../theme-library';
|
||||||
|
import {
|
||||||
|
eraseUserData,
|
||||||
|
exportUserData,
|
||||||
|
importUserData,
|
||||||
|
openCurrentDataFolder
|
||||||
|
} from '../data-management';
|
||||||
|
import { listRunningProcessNames } from '../process-list';
|
||||||
|
|
||||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const FILE_CLIPBOARD_FORMATS = [
|
const FILE_CLIPBOARD_FORMATS = [
|
||||||
@@ -313,6 +321,8 @@ export function setupSystemHandlers(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-running-process-names', async () => await listRunningProcessNames());
|
||||||
|
|
||||||
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
||||||
return await prepareLinuxScreenShareAudioRouting();
|
return await prepareLinuxScreenShareAudioRouting();
|
||||||
});
|
});
|
||||||
@@ -334,6 +344,10 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
||||||
|
ipcMain.handle('open-current-data-folder', async () => await openCurrentDataFolder());
|
||||||
|
ipcMain.handle('export-user-data', async () => await exportUserData());
|
||||||
|
ipcMain.handle('import-user-data', async () => await importUserData());
|
||||||
|
ipcMain.handle('erase-user-data', async () => await eraseUserData());
|
||||||
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
||||||
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
||||||
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
||||||
@@ -528,6 +542,7 @@ export function setupSystemHandlers(): void {
|
|||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
response.on('error', () => resolve(false));
|
response.on('error', () => resolve(false));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -537,7 +552,12 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('context-menu-command', (_event, command: string) => {
|
ipcMain.handle('context-menu-command', (_event, command: string) => {
|
||||||
const allowedCommands = ['cut', 'copy', 'paste', 'selectAll'] as const;
|
const allowedCommands = [
|
||||||
|
'cut',
|
||||||
|
'copy',
|
||||||
|
'paste',
|
||||||
|
'selectAll'
|
||||||
|
] as const;
|
||||||
|
|
||||||
if (!allowedCommands.includes(command as typeof allowedCommands[number])) {
|
if (!allowedCommands.includes(command as typeof allowedCommands[number])) {
|
||||||
return;
|
return;
|
||||||
@@ -551,10 +571,22 @@ export function setupSystemHandlers(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'cut': webContents.cut(); break;
|
case 'cut':
|
||||||
case 'copy': webContents.copy(); break;
|
webContents.cut();
|
||||||
case 'paste': webContents.paste(); break;
|
break;
|
||||||
case 'selectAll': webContents.selectAll(); break;
|
case 'copy':
|
||||||
|
webContents.copy();
|
||||||
|
break;
|
||||||
|
case 'paste':
|
||||||
|
webContents.paste();
|
||||||
|
break;
|
||||||
|
case 'selectAll':
|
||||||
|
webContents.selectAll();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-idle-state', () => {
|
||||||
|
return getIdleState();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddProfileAvatarMetadata1000000000006 implements MigrationInterface {
|
||||||
|
name = 'AddProfileAvatarMetadata1000000000006';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarHash" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarMime" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarUpdatedAt" INTEGER`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarHash" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarMime" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarUpdatedAt" INTEGER`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// SQLite column removal requires table rebuilds. Keep rollback no-op.
|
||||||
|
}
|
||||||
|
}
|
||||||
16
electron/migrations/1000000000007-AddUserProfileMetadata.ts
Normal file
16
electron/migrations/1000000000007-AddUserProfileMetadata.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUserProfileMetadata1000000000007 implements MigrationInterface {
|
||||||
|
name = 'AddUserProfileMetadata1000000000007';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "description" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "profileUpdatedAt" INTEGER`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "description" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "profileUpdatedAt" INTEGER`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// SQLite column removal requires table rebuilds. Keep rollback no-op.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monit
|
|||||||
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||||
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||||
|
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
|
||||||
|
|
||||||
export interface LinuxScreenShareAudioRoutingInfo {
|
export interface LinuxScreenShareAudioRoutingInfo {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -108,6 +109,24 @@ export interface SavedThemeFileDescriptor {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExportUserDataResult {
|
||||||
|
cancelled: boolean;
|
||||||
|
exported: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportUserDataResult {
|
||||||
|
backupPath?: string;
|
||||||
|
cancelled: boolean;
|
||||||
|
imported: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EraseUserDataResult {
|
||||||
|
erased: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function readLinuxDisplayServer(): string {
|
function readLinuxDisplayServer(): string {
|
||||||
if (process.platform !== 'linux') {
|
if (process.platform !== 'linux') {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@@ -148,6 +167,7 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
openExternal: (url: string) => Promise<boolean>;
|
openExternal: (url: string) => Promise<boolean>;
|
||||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||||
|
getRunningProcessNames: () => Promise<string[]>;
|
||||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||||
@@ -156,6 +176,10 @@ export interface ElectronAPI {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
|
importUserData: () => Promise<ImportUserDataResult>;
|
||||||
|
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||||
getSavedThemesPath: () => Promise<string>;
|
getSavedThemesPath: () => Promise<string>;
|
||||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||||
readSavedTheme: (fileName: string) => Promise<string>;
|
readSavedTheme: (fileName: string) => Promise<string>;
|
||||||
@@ -214,6 +238,9 @@ export interface ElectronAPI {
|
|||||||
contextMenuCommand: (command: string) => Promise<void>;
|
contextMenuCommand: (command: string) => Promise<void>;
|
||||||
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||||
|
|
||||||
|
getIdleState: () => Promise<'active' | 'idle'>;
|
||||||
|
onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void;
|
||||||
|
|
||||||
command: <T = unknown>(command: Command) => Promise<T>;
|
command: <T = unknown>(command: Command) => Promise<T>;
|
||||||
query: <T = unknown>(query: Query) => Promise<T>;
|
query: <T = unknown>(query: Query) => Promise<T>;
|
||||||
}
|
}
|
||||||
@@ -226,6 +253,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
|
|
||||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||||
getSources: () => ipcRenderer.invoke('get-sources'),
|
getSources: () => ipcRenderer.invoke('get-sources'),
|
||||||
|
getRunningProcessNames: () => ipcRenderer.invoke('get-running-process-names'),
|
||||||
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
|
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
|
||||||
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
|
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
|
||||||
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
|
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
|
||||||
@@ -261,6 +289,10 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
|
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
||||||
|
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
||||||
|
importUserData: () => ipcRenderer.invoke('import-user-data'),
|
||||||
|
eraseUserData: () => ipcRenderer.invoke('erase-user-data'),
|
||||||
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
||||||
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
||||||
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
||||||
@@ -333,6 +365,19 @@ const electronAPI: ElectronAPI = {
|
|||||||
contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command),
|
contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command),
|
||||||
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
|
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
|
||||||
|
|
||||||
|
getIdleState: () => ipcRenderer.invoke('get-idle-state'),
|
||||||
|
onIdleStateChanged: (listener) => {
|
||||||
|
const wrappedListener = (_event: Electron.IpcRendererEvent, state: 'active' | 'idle') => {
|
||||||
|
listener(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcRenderer.on(IDLE_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener(IDLE_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
||||||
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
||||||
};
|
};
|
||||||
|
|||||||
85
electron/process-list.ts
Normal file
85
electron/process-list.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { execFile } from 'child_process';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const MAX_PROCESS_NAMES = 512;
|
||||||
|
|
||||||
|
export async function listRunningProcessNames(): Promise<string[]> {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return normalizeProcessNames(await listWindowsProcessNames());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
return normalizeProcessNames(await listLinuxProcessNames());
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listLinuxProcessNames(): Promise<string[]> {
|
||||||
|
const { stdout } = await execFileAsync('ps', ['-eo', 'comm='], {
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
timeout: 5_000
|
||||||
|
});
|
||||||
|
|
||||||
|
return stdout.split('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listWindowsProcessNames(): Promise<string[]> {
|
||||||
|
const { stdout } = await execFileAsync('tasklist', [
|
||||||
|
'/FO',
|
||||||
|
'CSV',
|
||||||
|
'/NH'
|
||||||
|
], {
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
timeout: 5_000,
|
||||||
|
windowsHide: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => parseCsvFirstColumn(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsvFirstColumn(line: string): string {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed.startsWith('"')) {
|
||||||
|
return trimmed.split(',')[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const endQuoteIndex = trimmed.indexOf('"', 1);
|
||||||
|
|
||||||
|
return endQuoteIndex > 1 ? trimmed.slice(1, endQuoteIndex) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcessNames(names: string[]): string[] {
|
||||||
|
const normalized = new Set<string>();
|
||||||
|
|
||||||
|
for (const rawName of names) {
|
||||||
|
const name = normalizeProcessName(rawName);
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
normalized.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(normalized)
|
||||||
|
.sort()
|
||||||
|
.slice(0, MAX_PROCESS_NAMES);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcessName(rawName: string): string {
|
||||||
|
const baseName = path.basename(rawName.trim()).trim();
|
||||||
|
|
||||||
|
if (!baseName || baseName.length > 96) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseName;
|
||||||
|
}
|
||||||
@@ -500,7 +500,7 @@ async function performUpdateCheck(
|
|||||||
setDesktopUpdateState({
|
setDesktopUpdateState({
|
||||||
lastCheckedAt: Date.now(),
|
lastCheckedAt: Date.now(),
|
||||||
status: 'checking',
|
status: 'checking',
|
||||||
statusMessage: `Checking for MetoYou ${targetRelease.version}…`,
|
statusMessage: `Checking for MetoYou ${targetRelease.version}...`,
|
||||||
targetVersion: targetRelease.version
|
targetVersion: targetRelease.version
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -687,7 +687,7 @@ export function initializeDesktopUpdater(): void {
|
|||||||
|
|
||||||
setDesktopUpdateState({
|
setDesktopUpdateState({
|
||||||
status: 'checking',
|
status: 'checking',
|
||||||
statusMessage: 'Checking for desktop updates…'
|
statusMessage: 'Checking for desktop updates...'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -698,7 +698,7 @@ export function initializeDesktopUpdater(): void {
|
|||||||
setDesktopUpdateState({
|
setDesktopUpdateState({
|
||||||
lastCheckedAt: Date.now(),
|
lastCheckedAt: Date.now(),
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
statusMessage: `Downloading MetoYou ${nextVersion ?? 'update'}…`,
|
statusMessage: `Downloading MetoYou ${nextVersion ?? 'update'}...`,
|
||||||
targetVersion: nextVersion
|
targetVersion: nextVersion
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,8 +15,37 @@ let mainWindow: BrowserWindow | null = null;
|
|||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let closeToTrayEnabled = true;
|
let closeToTrayEnabled = true;
|
||||||
let appQuitting = false;
|
let appQuitting = false;
|
||||||
|
let youtubeRequestHeadersConfigured = false;
|
||||||
|
|
||||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||||
|
const YOUTUBE_EMBED_REFERRER = 'https://toju.app/';
|
||||||
|
|
||||||
|
function ensureYoutubeEmbedRequestHeaders(): void {
|
||||||
|
if (youtubeRequestHeadersConfigured || !app.isPackaged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
youtubeRequestHeadersConfigured = true;
|
||||||
|
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
|
{
|
||||||
|
urls: [
|
||||||
|
'https://www.youtube-nocookie.com/*',
|
||||||
|
'https://www.youtube.com/*',
|
||||||
|
'https://*.youtube.com/*',
|
||||||
|
'https://*.googlevideo.com/*',
|
||||||
|
'https://*.ytimg.com/*'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
(details, callback) => {
|
||||||
|
const requestHeaders = { ...details.requestHeaders };
|
||||||
|
|
||||||
|
requestHeaders['Referer'] ??= YOUTUBE_EMBED_REFERRER;
|
||||||
|
|
||||||
|
callback({ requestHeaders });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getAssetPath(...segments: string[]): string {
|
function getAssetPath(...segments: string[]): string {
|
||||||
const basePath = app.isPackaged
|
const basePath = app.isPackaged
|
||||||
@@ -163,6 +192,7 @@ export async function createWindow(): Promise<void> {
|
|||||||
|
|
||||||
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
||||||
ensureTray();
|
ensureTray();
|
||||||
|
ensureYoutubeEmbedRequestHeaders();
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
@@ -210,6 +240,40 @@ export async function createWindow(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
session.defaultSession.setDisplayMediaRequestHandler(
|
||||||
|
async (request, respond) => {
|
||||||
|
// On Windows the system picker (useSystemPicker: true) is preferred.
|
||||||
|
// This handler is only reached when the system picker is unavailable.
|
||||||
|
// Include loopback audio when the renderer requested it so that
|
||||||
|
// getDisplayMedia receives an audio track and the renderer-side
|
||||||
|
// restrictOwnAudio constraint can keep the app's own voice playback
|
||||||
|
// out of the captured stream.
|
||||||
|
try {
|
||||||
|
const sources = await desktopCapturer.getSources({
|
||||||
|
types: ['window', 'screen'],
|
||||||
|
thumbnailSize: { width: 150, height: 150 }
|
||||||
|
});
|
||||||
|
const firstSource = sources[0];
|
||||||
|
|
||||||
|
if (firstSource) {
|
||||||
|
respond({
|
||||||
|
video: firstSource,
|
||||||
|
...(request.audioRequested ? { audio: 'loopback' } : {})
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// desktopCapturer also unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
respond({});
|
||||||
|
},
|
||||||
|
{ useSystemPicker: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env['NODE_ENV'] === 'development') {
|
if (process.env['NODE_ENV'] === 'development') {
|
||||||
const devUrl = process.env['SSL'] === 'true'
|
const devUrl = process.env['SSL'] === 'true'
|
||||||
? 'https://localhost:4200'
|
? 'https://localhost:4200'
|
||||||
|
|||||||
@@ -5,41 +5,7 @@ const angular = require('angular-eslint');
|
|||||||
const stylisticTs = require('@stylistic/eslint-plugin-ts');
|
const stylisticTs = require('@stylistic/eslint-plugin-ts');
|
||||||
const stylisticJs = require('@stylistic/eslint-plugin-js');
|
const stylisticJs = require('@stylistic/eslint-plugin-js');
|
||||||
const newlines = require('eslint-plugin-import-newlines');
|
const newlines = require('eslint-plugin-import-newlines');
|
||||||
|
const metoyouEslintRules = require('./tools/eslint-rules');
|
||||||
// Inline plugin: ban en dash (–, U+2013) and em dash (—, U+2014) from source files
|
|
||||||
const noDashPlugin = {
|
|
||||||
rules: {
|
|
||||||
'no-unicode-dashes': {
|
|
||||||
meta: { fixable: 'code' },
|
|
||||||
create(context) {
|
|
||||||
const BANNED = [
|
|
||||||
{ char: '\u2013', name: 'en dash (–)' },
|
|
||||||
{ char: '\u2014', name: 'em dash (—)' }
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
Program() {
|
|
||||||
const src = context.getSourceCode().getText();
|
|
||||||
for (const { char, name } of BANNED) {
|
|
||||||
let idx = src.indexOf(char);
|
|
||||||
while (idx !== -1) {
|
|
||||||
const start = idx;
|
|
||||||
const end = idx + char.length;
|
|
||||||
context.report({
|
|
||||||
loc: context.getSourceCode().getLocFromIndex(idx),
|
|
||||||
message: `Unicode ${name} is not allowed. Use a regular hyphen (-) instead.`,
|
|
||||||
fix(fixer) {
|
|
||||||
return fixer.replaceTextRange([start, end], '-');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
idx = src.indexOf(char, idx + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = tseslint.config(
|
module.exports = tseslint.config(
|
||||||
{
|
{
|
||||||
@@ -51,7 +17,7 @@ module.exports = tseslint.config(
|
|||||||
'@stylistic/ts': stylisticTs,
|
'@stylistic/ts': stylisticTs,
|
||||||
'@stylistic/js': stylisticJs,
|
'@stylistic/js': stylisticJs,
|
||||||
'import-newlines': newlines,
|
'import-newlines': newlines,
|
||||||
'no-dashes': noDashPlugin
|
'metoyou': metoyouEslintRules
|
||||||
},
|
},
|
||||||
extends: [
|
extends: [
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
@@ -69,7 +35,7 @@ module.exports = tseslint.config(
|
|||||||
styles: 0
|
styles: 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
'no-dashes/no-unicode-dashes': 'error',
|
'metoyou/no-unicode-symbols': 'error',
|
||||||
'@typescript-eslint/no-extraneous-class': 'off',
|
'@typescript-eslint/no-extraneous-class': 'off',
|
||||||
'@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ],
|
'@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ],
|
||||||
'@angular-eslint/directive-class-suffix': 'error',
|
'@angular-eslint/directive-class-suffix': 'error',
|
||||||
@@ -123,7 +89,7 @@ module.exports = tseslint.config(
|
|||||||
'complexity': ['warn',{ max:20 }],
|
'complexity': ['warn',{ max:20 }],
|
||||||
'curly': 'off',
|
'curly': 'off',
|
||||||
'eol-last': 'error',
|
'eol-last': 'error',
|
||||||
'id-denylist': ['warn','e','cb','i','x','c','y','any','string','String','Undefined','undefined','callback'],
|
'id-denylist': ['warn','e','cb','i','c','any','string','String','Undefined','undefined','callback'],
|
||||||
'max-len': ['error',{ code:150, ignoreComments:true }],
|
'max-len': ['error',{ code:150, ignoreComments:true }],
|
||||||
'new-parens': 'error',
|
'new-parens': 'error',
|
||||||
'newline-per-chained-call': 'error',
|
'newline-per-chained-call': 'error',
|
||||||
@@ -172,7 +138,7 @@ module.exports = tseslint.config(
|
|||||||
// Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); }
|
// Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); }
|
||||||
'max-statements-per-line': ['error', { max: 1 }],
|
'max-statements-per-line': ['error', { max: 1 }],
|
||||||
// Prevent single-character identifiers for variables/params; do not check object property names
|
// Prevent single-character identifiers for variables/params; do not check object property names
|
||||||
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_'] }],
|
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_', 'x', 'y'] }],
|
||||||
// Require blank lines around block-like statements (if, function, class, switch, try, etc.)
|
// Require blank lines around block-like statements (if, function, class, switch, try, etc.)
|
||||||
'padding-line-between-statements': [
|
'padding-line-between-statements': [
|
||||||
'error',
|
'error',
|
||||||
@@ -200,10 +166,10 @@ module.exports = tseslint.config(
|
|||||||
// HTML template formatting rules (external Angular templates only)
|
// HTML template formatting rules (external Angular templates only)
|
||||||
{
|
{
|
||||||
files: ['toju-app/src/app/**/*.html'],
|
files: ['toju-app/src/app/**/*.html'],
|
||||||
plugins: { 'no-dashes': noDashPlugin },
|
plugins: { 'metoyou': metoyouEslintRules },
|
||||||
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
||||||
rules: {
|
rules: {
|
||||||
'no-dashes/no-unicode-dashes': 'error',
|
'metoyou/no-unicode-symbols': 'error',
|
||||||
// Angular template best practices
|
// Angular template best practices
|
||||||
'@angular-eslint/template/button-has-type': 'warn',
|
'@angular-eslint/template/button-has-type': 'warn',
|
||||||
'@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }],
|
'@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }],
|
||||||
|
|||||||
399
package-lock.json
generated
399
package-lock.json
generated
@@ -15,8 +15,10 @@
|
|||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/lint": "^6.9.5",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.41.0",
|
"@codemirror/view": "^6.41.0",
|
||||||
@@ -60,6 +62,7 @@
|
|||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
"@types/auto-launch": "^5.0.5",
|
"@types/auto-launch": "^5.0.5",
|
||||||
|
"@types/mocha": "^10.0.10",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -79,6 +82,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.50.1",
|
"typescript-eslint": "8.50.1",
|
||||||
|
"vitest": "^4.1.4",
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^7.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2729,6 +2733,19 @@
|
|||||||
"@lezer/common": "^1.1.0"
|
"@lezer/common": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/lang-css": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@lezer/common": "^1.0.2",
|
||||||
|
"@lezer/css": "^1.1.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/lang-json": {
|
"node_modules/@codemirror/lang-json": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||||
@@ -5789,6 +5806,17 @@
|
|||||||
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/css": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@lezer/highlight": {
|
"node_modules/@lezer/highlight": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
@@ -11025,6 +11053,17 @@
|
|||||||
"@types/responselike": "^1.0.0"
|
"@types/responselike": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/connect": {
|
"node_modules/@types/connect": {
|
||||||
"version": "3.4.38",
|
"version": "3.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
@@ -11306,6 +11345,13 @@
|
|||||||
"@types/ms": "*"
|
"@types/ms": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/eslint": {
|
"node_modules/@types/eslint": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||||
@@ -11465,6 +11511,13 @@
|
|||||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/mocha": {
|
||||||
|
"version": "10.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz",
|
||||||
|
"integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/ms": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
@@ -12270,6 +12323,146 @@
|
|||||||
"vite": "^6.0.0 || ^7.0.0"
|
"vite": "^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "4.1.4",
|
||||||
|
"@vitest/utils": "4.1.4",
|
||||||
|
"chai": "^6.2.2",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "4.1.4",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.21"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker/node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "4.1.4",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.4",
|
||||||
|
"@vitest/utils": "4.1.4",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot/node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.4",
|
||||||
|
"convert-source-map": "^2.0.0",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils/node_modules/convert-source-map": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@webassemblyjs/ast": {
|
"node_modules/@webassemblyjs/ast": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||||
@@ -13108,6 +13301,16 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/astral-regex": {
|
"node_modules/astral-regex": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
||||||
@@ -14004,6 +14207,16 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||||
@@ -17483,6 +17696,16 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esutils": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
@@ -17562,6 +17785,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exponential-backoff": {
|
"node_modules/exponential-backoff": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||||
@@ -23538,6 +23771,17 @@
|
|||||||
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/obug": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/sxzz",
|
||||||
|
"https://opencollective.com/debug"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -27379,6 +27623,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/signal-exit": {
|
"node_modules/signal-exit": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
@@ -27773,6 +28024,13 @@
|
|||||||
"node": "^20.17.0 || >=22.9.0"
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stackframe": {
|
"node_modules/stackframe": {
|
||||||
"version": "1.3.4",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
||||||
@@ -27798,6 +28056,13 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stdin-discarder": {
|
"node_modules/stdin-discarder": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
|
||||||
@@ -28671,6 +28936,13 @@
|
|||||||
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyexec": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||||
@@ -28696,6 +28968,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tmp": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||||
@@ -30567,6 +30849,106 @@
|
|||||||
"@esbuild/win32-x64": "0.25.12"
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/expect": "4.1.4",
|
||||||
|
"@vitest/mocker": "4.1.4",
|
||||||
|
"@vitest/pretty-format": "4.1.4",
|
||||||
|
"@vitest/runner": "4.1.4",
|
||||||
|
"@vitest/snapshot": "4.1.4",
|
||||||
|
"@vitest/spy": "4.1.4",
|
||||||
|
"@vitest/utils": "4.1.4",
|
||||||
|
"es-module-lexer": "^2.0.0",
|
||||||
|
"expect-type": "^1.3.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"std-env": "^4.0.0-rc.1",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^1.0.2",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"tinyrainbow": "^3.1.0",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
|
"@vitest/browser-playwright": "4.1.4",
|
||||||
|
"@vitest/browser-preview": "4.1.4",
|
||||||
|
"@vitest/browser-webdriverio": "4.1.4",
|
||||||
|
"@vitest/coverage-istanbul": "4.1.4",
|
||||||
|
"@vitest/coverage-v8": "4.1.4",
|
||||||
|
"@vitest/ui": "4.1.4",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-preview": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-webdriverio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-istanbul": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-v8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vscode-jsonrpc": {
|
"node_modules/vscode-jsonrpc": {
|
||||||
"version": "8.2.0",
|
"version": "8.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||||
@@ -31439,6 +31821,23 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wildcard": {
|
"node_modules/wildcard": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -17,7 +17,7 @@
|
|||||||
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
||||||
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
||||||
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
||||||
"test": "cd \"toju-app\" && ng test",
|
"test": "cd \"toju-app\" && vitest run",
|
||||||
"server:build": "cd server && npm run build",
|
"server:build": "cd server && npm run build",
|
||||||
"server:start": "cd server && npm start",
|
"server:start": "cd server && npm start",
|
||||||
"server:dev": "cd server && npm run dev",
|
"server:dev": "cd server && npm run dev",
|
||||||
@@ -65,8 +65,10 @@
|
|||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/lint": "^6.9.5",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.41.0",
|
"@codemirror/view": "^6.41.0",
|
||||||
@@ -110,6 +112,7 @@
|
|||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
"@types/auto-launch": "^5.0.5",
|
"@types/auto-launch": "^5.0.5",
|
||||||
|
"@types/mocha": "^10.0.10",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -129,6 +132,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.50.1",
|
"typescript-eslint": "8.50.1",
|
||||||
|
"vitest": "^4.1.4",
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^7.2.0"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
@@ -146,9 +150,13 @@
|
|||||||
"output": "dist-electron"
|
"output": "dist-electron"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
"!node_modules",
|
||||||
"dist/client/**/*",
|
"dist/client/**/*",
|
||||||
"dist/electron/**/*",
|
"dist/electron/**/*",
|
||||||
"node_modules/**/*",
|
"node_modules/{ansi-regex,ansi-styles,ansis,app-root-path,applescript,argparse,auto-launch,available-typed-arrays,balanced-match,base64-js,brace-expansion,buffer,builder-util-runtime,call-bind,call-bind-apply-helpers,call-bound,cliui,concat-map,cross-spawn,dayjs,debug,dedent,define-data-property,dotenv,dunder-proto,electron-updater,emoji-regex,es-define-property,es-errors,es-object-atoms,escalade,for-each,foreground-child,fs-extra,function-bind,get-caller-file,get-east-asian-width,get-intrinsic,get-proto,glob,gopd,graceful-fs,has-property-descriptors,has-symbols,has-tostringtag,hasown,ieee754,inherits,is-callable,is-fullwidth-code-point,is-typed-array,isarray,isexe,jackspeak,js-yaml,jsonfile,lazy-val,lodash.escaperegexp,lodash.isequal,lru-cache,math-intrinsics,minimatch,minimist,minipass,mkdirp,ms,package-json-from-dist,path-is-absolute,path-key,path-scurry,possible-typed-array-names,reflect-metadata,safe-buffer,sax,semver,set-function-length,sha.js,shebang-command,shebang-regex,signal-exit,sql-highlight,sql.js,string-width,string-width-cjs,strip-ansi,strip-ansi-cjs,tiny-typed-emitter,to-buffer,tslib,typed-array-buffer,typeorm,universalify,untildify,uuid,which,which-typed-array,winreg,wrap-ansi,wrap-ansi-cjs,y18n,yallist,yargs,yargs-parser}/**/*",
|
||||||
|
"node_modules/@isaacs/cliui/**/*",
|
||||||
|
"node_modules/@pkgjs/parseargs/**/*",
|
||||||
|
"node_modules/@sqltools/formatter/**/*",
|
||||||
"!node_modules/**/test/**/*",
|
"!node_modules/**/test/**/*",
|
||||||
"!node_modules/**/tests/**/*",
|
"!node_modules/**/tests/**/*",
|
||||||
"!node_modules/**/*.d.ts",
|
"!node_modules/**/*.d.ts",
|
||||||
|
|||||||
43
server/README.md
Normal file
43
server/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Server
|
||||||
|
|
||||||
|
Node/TypeScript signaling server for MetoYou / Toju. This package owns the public server-directory API, join-request flows, websocket runtime, and server-side persistence.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Run `cd server`.
|
||||||
|
2. Run `npm install`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `npm run dev` starts the server with `ts-node-dev` reload.
|
||||||
|
- `npm run build` compiles TypeScript to `dist/`.
|
||||||
|
- `npm run start` runs the compiled server.
|
||||||
|
- From the repository root, `npm run server:dev`, `npm run server:build`, and `npm run server:start` call the same package commands.
|
||||||
|
|
||||||
|
## Runtime Config
|
||||||
|
|
||||||
|
- The server loads the repository-root `.env` file on startup.
|
||||||
|
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
|
||||||
|
- `DB_PATH` can override the SQLite database file location.
|
||||||
|
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
|
||||||
|
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
|
||||||
|
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
|
||||||
|
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/index.ts` | Bootstrap and server startup |
|
||||||
|
| `src/app/` | Express app composition |
|
||||||
|
| `src/routes/` | REST API routes |
|
||||||
|
| `src/websocket/` | WebSocket runtime and signaling transport |
|
||||||
|
| `src/cqrs/` | Command/query handlers |
|
||||||
|
| `src/config/` | Runtime config loading and normalization |
|
||||||
|
| `src/db/`, `src/entities/`, `src/migrations/` | Persistence layer |
|
||||||
|
| `data/` | Runtime data files such as `variables.json` |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `dist/` and `../dist-server/` are generated output.
|
||||||
|
- See [AGENTS.md](AGENTS.md) for package-specific editing guidance.
|
||||||
Binary file not shown.
@@ -12,6 +12,7 @@ export interface LinkPreviewConfig {
|
|||||||
|
|
||||||
export interface ServerVariablesConfig {
|
export interface ServerVariablesConfig {
|
||||||
klipyApiKey: string;
|
klipyApiKey: string;
|
||||||
|
rawgApiKey: string;
|
||||||
releaseManifestUrl: string;
|
releaseManifestUrl: string;
|
||||||
serverPort: number;
|
serverPort: number;
|
||||||
serverProtocol: ServerHttpProtocol;
|
serverProtocol: ServerHttpProtocol;
|
||||||
@@ -31,6 +32,10 @@ function normalizeKlipyApiKey(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRawgApiKey(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeReleaseManifestUrl(value: unknown): string {
|
function normalizeReleaseManifestUrl(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
@@ -139,6 +144,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
const normalized = {
|
const normalized = {
|
||||||
...remainingParsed,
|
...remainingParsed,
|
||||||
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
||||||
|
rawgApiKey: normalizeRawgApiKey(remainingParsed.rawgApiKey),
|
||||||
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
||||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||||
@@ -153,6 +159,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
klipyApiKey: normalized.klipyApiKey,
|
klipyApiKey: normalized.klipyApiKey,
|
||||||
|
rawgApiKey: normalized.rawgApiKey,
|
||||||
releaseManifestUrl: normalized.releaseManifestUrl,
|
releaseManifestUrl: normalized.releaseManifestUrl,
|
||||||
serverPort: normalized.serverPort,
|
serverPort: normalized.serverPort,
|
||||||
serverProtocol: normalized.serverProtocol,
|
serverProtocol: normalized.serverProtocol,
|
||||||
@@ -169,6 +176,14 @@ export function getKlipyApiKey(): string {
|
|||||||
return getVariablesConfig().klipyApiKey;
|
return getVariablesConfig().klipyApiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRawgApiKey(): string {
|
||||||
|
if (hasEnvironmentOverride(process.env.RAWG_API_KEY)) {
|
||||||
|
return process.env.RAWG_API_KEY.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return getVariablesConfig().rawgApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
export function hasKlipyApiKey(): boolean {
|
export function hasKlipyApiKey(): boolean {
|
||||||
return getKlipyApiKey().length > 0;
|
return getKlipyApiKey().length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import fsp from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
@@ -12,16 +14,134 @@ import {
|
|||||||
JoinRequestEntity,
|
JoinRequestEntity,
|
||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
ServerBanEntity
|
ServerBanEntity,
|
||||||
|
GameMatchMissEntity
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { serverMigrations } from '../migrations';
|
import { serverMigrations } from '../migrations';
|
||||||
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
import {
|
||||||
|
findExistingPath,
|
||||||
|
isPackagedRuntime,
|
||||||
|
resolvePersistentDataPath,
|
||||||
|
resolveRuntimePath
|
||||||
|
} from '../runtime-paths';
|
||||||
|
|
||||||
const DATA_DIR = resolveRuntimePath('data');
|
const LEGACY_PACKAGED_DB_FILE = path.join(resolveRuntimePath('data'), 'metoyou.sqlite');
|
||||||
const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite');
|
const LEGACY_PACKAGED_DB_BACKUP = LEGACY_PACKAGED_DB_FILE + '.bak';
|
||||||
|
|
||||||
|
function resolveDefaultDbFile(): string {
|
||||||
|
return isPackagedRuntime()
|
||||||
|
? resolvePersistentDataPath('metoyou.sqlite')
|
||||||
|
: LEGACY_PACKAGED_DB_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDbFile(): string {
|
||||||
|
const envPath = process.env.DB_PATH;
|
||||||
|
|
||||||
|
if (envPath) {
|
||||||
|
return path.resolve(envPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveDefaultDbFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_FILE = resolveDbFile();
|
||||||
|
const DB_BACKUP = DB_FILE + '.bak';
|
||||||
|
const DATA_DIR = path.dirname(DB_FILE);
|
||||||
|
// SQLite files start with this 16-byte header string.
|
||||||
|
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||||
|
|
||||||
let applicationDataSource: DataSource | undefined;
|
let applicationDataSource: DataSource | undefined;
|
||||||
|
|
||||||
|
function restoreFromBackup(reason: string): Uint8Array | undefined {
|
||||||
|
if (!fs.existsSync(DB_BACKUP)) {
|
||||||
|
console.error(`[DB] ${reason}. No backup available - starting with a fresh database`);
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = new Uint8Array(fs.readFileSync(DB_BACKUP));
|
||||||
|
|
||||||
|
if (!isValidSqlite(backup)) {
|
||||||
|
console.error(`[DB] ${reason}. Backup is also invalid - starting with a fresh database`);
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.copyFileSync(DB_BACKUP, DB_FILE);
|
||||||
|
console.warn('[DB] Restored database from backup', DB_BACKUP);
|
||||||
|
|
||||||
|
return backup;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateLegacyPackagedDatabase(): Promise<void> {
|
||||||
|
if (process.env.DB_PATH || !isPackagedRuntime() || path.resolve(DB_FILE) === path.resolve(LEGACY_PACKAGED_DB_FILE)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let migrated = false;
|
||||||
|
|
||||||
|
if (!fs.existsSync(DB_FILE)) {
|
||||||
|
if (fs.existsSync(LEGACY_PACKAGED_DB_FILE)) {
|
||||||
|
await fsp.copyFile(LEGACY_PACKAGED_DB_FILE, DB_FILE);
|
||||||
|
migrated = true;
|
||||||
|
} else if (fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) {
|
||||||
|
await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_FILE);
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(DB_BACKUP) && fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) {
|
||||||
|
await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_BACKUP);
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrated) {
|
||||||
|
console.log('[DB] Migrated packaged database files to:', DATA_DIR);
|
||||||
|
console.log('[DB] Legacy packaged database location was:', LEGACY_PACKAGED_DB_FILE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when `data` looks like a valid SQLite file
|
||||||
|
* (correct header magic and at least one complete page).
|
||||||
|
*/
|
||||||
|
function isValidSqlite(data: Uint8Array): boolean {
|
||||||
|
if (data.length < 100)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const header = Buffer.from(data.buffer, data.byteOffset, 16).toString('ascii');
|
||||||
|
|
||||||
|
return header === SQLITE_MAGIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back up the current DB file so there is always a recovery point.
|
||||||
|
* If the main file is corrupted/empty but a valid backup exists,
|
||||||
|
* restore the backup before the server loads the database.
|
||||||
|
*/
|
||||||
|
function safeguardDbFile(): Uint8Array | undefined {
|
||||||
|
if (!fs.existsSync(DB_FILE)) {
|
||||||
|
console.warn(`[DB] ${DB_FILE} is missing - checking backup`);
|
||||||
|
|
||||||
|
return restoreFromBackup('Database file missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Uint8Array(fs.readFileSync(DB_FILE));
|
||||||
|
|
||||||
|
if (isValidSqlite(data)) {
|
||||||
|
// Good file - rotate it into the backup slot.
|
||||||
|
fs.copyFileSync(DB_FILE, DB_BACKUP);
|
||||||
|
console.log('[DB] Backed up database to', DB_BACKUP);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The main file is corrupt or empty.
|
||||||
|
console.warn(`[DB] ${DB_FILE} appears corrupt (${data.length} bytes) - checking backup`);
|
||||||
|
|
||||||
|
return restoreFromBackup(`Database file is invalid (${data.length} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
||||||
return {
|
return {
|
||||||
locateFile: (file) => {
|
locateFile: (file) => {
|
||||||
@@ -35,6 +155,23 @@ function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the database to disk atomically: write a temp file first,
|
||||||
|
* then rename it over the real file. rename() is atomic on the same
|
||||||
|
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||||
|
*/
|
||||||
|
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||||
|
const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fsp.writeFile(tmpPath, Buffer.from(data));
|
||||||
|
await fsp.rename(tmpPath, DB_FILE);
|
||||||
|
} catch (err) {
|
||||||
|
await fsp.unlink(tmpPath).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getDataSource(): DataSource {
|
export function getDataSource(): DataSource {
|
||||||
if (!applicationDataSource?.isInitialized) {
|
if (!applicationDataSource?.isInitialized) {
|
||||||
throw new Error('DataSource not initialised');
|
throw new Error('DataSource not initialised');
|
||||||
@@ -47,10 +184,9 @@ export async function initDatabase(): Promise<void> {
|
|||||||
if (!fs.existsSync(DATA_DIR))
|
if (!fs.existsSync(DATA_DIR))
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
let database: Uint8Array | undefined;
|
await migrateLegacyPackagedDatabase();
|
||||||
|
|
||||||
if (fs.existsSync(DB_FILE))
|
const database = safeguardDbFile();
|
||||||
database = fs.readFileSync(DB_FILE);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
applicationDataSource = new DataSource({
|
applicationDataSource = new DataSource({
|
||||||
@@ -67,13 +203,14 @@ export async function initDatabase(): Promise<void> {
|
|||||||
JoinRequestEntity,
|
JoinRequestEntity,
|
||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
ServerBanEntity
|
ServerBanEntity,
|
||||||
|
GameMatchMissEntity
|
||||||
],
|
],
|
||||||
migrations: serverMigrations,
|
migrations: serverMigrations,
|
||||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||||
logging: false,
|
logging: false,
|
||||||
autoSave: true,
|
autoSave: true,
|
||||||
location: DB_FILE,
|
autoSaveCallback: atomicSave,
|
||||||
sqlJsConfig: resolveSqlJsConfig()
|
sqlJsConfig: resolveSqlJsConfig()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -94,7 +231,7 @@ export async function initDatabase(): Promise<void> {
|
|||||||
await applicationDataSource.runMigrations();
|
await applicationDataSource.runMigrations();
|
||||||
console.log('[DB] Migrations executed');
|
console.log('[DB] Migrations executed');
|
||||||
} else {
|
} else {
|
||||||
console.log('[DB] Synchronize mode — migrations skipped');
|
console.log('[DB] Synchronize mode - migrations skipped');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
server/src/entities/GameMatchMissEntity.ts
Normal file
22
server/src/entities/GameMatchMissEntity.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('game_match_misses')
|
||||||
|
export class GameMatchMissEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
processKey!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
processName!: string;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
missedAt!: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('integer')
|
||||||
|
expiresAt!: number;
|
||||||
|
}
|
||||||
@@ -9,3 +9,4 @@ export { JoinRequestEntity } from './JoinRequestEntity';
|
|||||||
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||||
export { ServerBanEntity } from './ServerBanEntity';
|
export { ServerBanEntity } from './ServerBanEntity';
|
||||||
|
export { GameMatchMissEntity } from './GameMatchMissEntity';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { resolveCertificateDirectory, resolveEnvFilePath } from './runtime-paths
|
|||||||
// Load .env from project root (one level up from server/)
|
// Load .env from project root (one level up from server/)
|
||||||
dotenv.config({ path: resolveEnvFilePath() });
|
dotenv.config({ path: resolveEnvFilePath() });
|
||||||
|
|
||||||
import { initDatabase } from './db/database';
|
import { initDatabase, destroyDatabase } from './db/database';
|
||||||
import { deleteStaleJoinRequests } from './cqrs';
|
import { deleteStaleJoinRequests } from './cqrs';
|
||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +59,9 @@ function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHt
|
|||||||
return createHttpServer(app);
|
return createHttpServer(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let listeningServer: ReturnType<typeof buildServer> | null = null;
|
||||||
|
let staleJoinRequestInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const variablesConfig = ensureVariablesConfig();
|
const variablesConfig = ensureVariablesConfig();
|
||||||
const serverProtocol = getServerProtocol();
|
const serverProtocol = getServerProtocol();
|
||||||
@@ -86,10 +89,12 @@ async function bootstrap(): Promise<void> {
|
|||||||
const app = createApp();
|
const app = createApp();
|
||||||
const server = buildServer(app, serverProtocol);
|
const server = buildServer(app, serverProtocol);
|
||||||
|
|
||||||
|
listeningServer = server;
|
||||||
|
|
||||||
setupWebSocket(server);
|
setupWebSocket(server);
|
||||||
|
|
||||||
// Periodically clean up stale join requests (older than 24 h)
|
// Periodically clean up stale join requests (older than 24 h)
|
||||||
setInterval(() => {
|
staleJoinRequestInterval = setInterval(() => {
|
||||||
deleteStaleJoinRequests(24 * 60 * 60 * 1000)
|
deleteStaleJoinRequests(24 * 60 * 60 * 1000)
|
||||||
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
@@ -119,6 +124,45 @@ async function bootstrap(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
async function gracefulShutdown(signal: string): Promise<void> {
|
||||||
|
if (shuttingDown)
|
||||||
|
return;
|
||||||
|
|
||||||
|
shuttingDown = true;
|
||||||
|
|
||||||
|
if (staleJoinRequestInterval) {
|
||||||
|
clearInterval(staleJoinRequestInterval);
|
||||||
|
staleJoinRequestInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n[Shutdown] ${signal} received - closing database...`);
|
||||||
|
|
||||||
|
if (listeningServer?.listening) {
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
listeningServer?.close(() => resolve());
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Shutdown] Error closing server:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listeningServer = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await destroyDatabase();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Shutdown] Error closing database:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
|
|
||||||
bootstrap().catch((err) => {
|
bootstrap().catch((err) => {
|
||||||
console.error('Failed to start server:', err);
|
console.error('Failed to start server:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ export class ServerChannels1000000000002 implements MigrationInterface {
|
|||||||
name = 'ServerChannels1000000000002';
|
name = 'ServerChannels1000000000002';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
|
const columns: { name: string }[] = await queryRunner.query(`PRAGMA table_info("servers")`);
|
||||||
|
const hasChannels = columns.some(c => c.name === 'channels');
|
||||||
|
if (!hasChannels) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
|||||||
24
server/src/migrations/1000000000006-GameMatchMisses.ts
Normal file
24
server/src/migrations/1000000000006-GameMatchMisses.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class GameMatchMisses1000000000006 implements MigrationInterface {
|
||||||
|
name = 'GameMatchMisses1000000000006';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "game_match_misses" (
|
||||||
|
"processKey" TEXT PRIMARY KEY NOT NULL,
|
||||||
|
"processName" TEXT NOT NULL,
|
||||||
|
"missedAt" INTEGER NOT NULL,
|
||||||
|
"expiresAt" INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_game_match_misses_expiresAt"
|
||||||
|
ON "game_match_misses" ("expiresAt")
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "game_match_misses"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
|
|||||||
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
|
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
|
||||||
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
||||||
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
||||||
|
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
|
||||||
|
|
||||||
export const serverMigrations = [
|
export const serverMigrations = [
|
||||||
InitialSchema1000000000000,
|
InitialSchema1000000000000,
|
||||||
@@ -11,5 +12,6 @@ export const serverMigrations = [
|
|||||||
ServerChannels1000000000002,
|
ServerChannels1000000000002,
|
||||||
RepairLegacyVoiceChannels1000000000003,
|
RepairLegacyVoiceChannels1000000000003,
|
||||||
NormalizeServerArrays1000000000004,
|
NormalizeServerArrays1000000000004,
|
||||||
ServerRoleAccessControl1000000000005
|
ServerRoleAccessControl1000000000005,
|
||||||
|
GameMatchMisses1000000000006
|
||||||
];
|
];
|
||||||
|
|||||||
17
server/src/routes/games.ts
Normal file
17
server/src/routes/games.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { matchRunningGames } from '../services/game-matching.service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/match', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await matchRunningGames(req.body?.processes, req.body?.userId ?? req.ip);
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Games] Failed to match running games', error);
|
||||||
|
res.status(500).json({ error: 'Failed to match running games' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -2,6 +2,7 @@ import { Express } from 'express';
|
|||||||
import healthRouter from './health';
|
import healthRouter from './health';
|
||||||
import klipyRouter from './klipy';
|
import klipyRouter from './klipy';
|
||||||
import linkMetadataRouter from './link-metadata';
|
import linkMetadataRouter from './link-metadata';
|
||||||
|
import gamesRouter from './games';
|
||||||
import proxyRouter from './proxy';
|
import proxyRouter from './proxy';
|
||||||
import usersRouter from './users';
|
import usersRouter from './users';
|
||||||
import serversRouter from './servers';
|
import serversRouter from './servers';
|
||||||
@@ -12,6 +13,7 @@ export function registerRoutes(app: Express): void {
|
|||||||
app.use('/api', healthRouter);
|
app.use('/api', healthRouter);
|
||||||
app.use('/api', klipyRouter);
|
app.use('/api', klipyRouter);
|
||||||
app.use('/api', linkMetadataRouter);
|
app.use('/api', linkMetadataRouter);
|
||||||
|
app.use('/api/games', gamesRouter);
|
||||||
app.use('/api', proxyRouter);
|
app.use('/api', proxyRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/servers', serversRouter);
|
app.use('/api/servers', serversRouter);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable complexity */
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
|
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
|
||||||
|
|
||||||
@@ -47,6 +46,11 @@ interface KlipyApiResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResolvedGifMedia {
|
||||||
|
previewMeta: NormalizedMediaMeta | null;
|
||||||
|
sourceMeta: NormalizedMediaMeta;
|
||||||
|
}
|
||||||
|
|
||||||
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
|
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (value != null)
|
if (value != null)
|
||||||
@@ -130,33 +134,49 @@ function extractKlipyResponseData(payload: unknown): { items: unknown[]; hasNext
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveGifMedia(file?: KlipyGifVariants): ResolvedGifMedia | null {
|
||||||
|
const previewVariant = pickFirst(file?.md, file?.sm, file?.xs, file?.hd);
|
||||||
|
const sourceVariant = pickFirst(file?.hd, file?.md, file?.sm, file?.xs);
|
||||||
|
const previewMeta = pickGifMeta(previewVariant);
|
||||||
|
const sourceMeta = pickGifMeta(sourceVariant) ?? previewMeta;
|
||||||
|
|
||||||
|
if (!sourceMeta?.url)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
previewMeta,
|
||||||
|
sourceMeta
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGifSlug(gifItem: KlipyGifItem): string | undefined {
|
||||||
|
return sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeGifItem(item: unknown): NormalizedKlipyGif | null {
|
function normalizeGifItem(item: unknown): NormalizedKlipyGif | null {
|
||||||
if (!item || typeof item !== 'object')
|
if (!item || typeof item !== 'object')
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
const gifItem = item as KlipyGifItem;
|
const gifItem = item as KlipyGifItem;
|
||||||
|
const resolvedMedia = resolveGifMedia(gifItem.file);
|
||||||
|
const slug = resolveGifSlug(gifItem);
|
||||||
|
|
||||||
if (gifItem.type === 'ad')
|
if (gifItem.type === 'ad')
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
const lowVariant = pickFirst(gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs, gifItem.file?.hd);
|
if (!slug || !resolvedMedia)
|
||||||
const highVariant = pickFirst(gifItem.file?.hd, gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs);
|
|
||||||
const lowMeta = pickGifMeta(lowVariant);
|
|
||||||
const highMeta = pickGifMeta(highVariant);
|
|
||||||
const selectedMeta = highMeta ?? lowMeta;
|
|
||||||
const slug = sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
|
|
||||||
|
|
||||||
if (!slug || !selectedMeta?.url)
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
const { previewMeta, sourceMeta } = resolvedMedia;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: slug,
|
id: slug,
|
||||||
slug,
|
slug,
|
||||||
title: sanitizeString(gifItem.title),
|
title: sanitizeString(gifItem.title),
|
||||||
url: selectedMeta.url,
|
url: sourceMeta.url,
|
||||||
previewUrl: lowMeta?.url ?? selectedMeta.url,
|
previewUrl: previewMeta?.url ?? sourceMeta.url,
|
||||||
width: selectedMeta.width ?? lowMeta?.width ?? 0,
|
width: sourceMeta.width ?? previewMeta?.width ?? 0,
|
||||||
height: selectedMeta.height ?? lowMeta?.height ?? 0
|
height: sourceMeta.height ?? previewMeta?.height ?? 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import {
|
|||||||
ServerAccessError,
|
ServerAccessError,
|
||||||
kickServerUser,
|
kickServerUser,
|
||||||
ensureServerMembership,
|
ensureServerMembership,
|
||||||
unbanServerUser
|
unbanServerUser,
|
||||||
|
countServerMemberships
|
||||||
} from '../services/server-access.service';
|
} from '../services/server-access.service';
|
||||||
import {
|
import {
|
||||||
buildAppInviteUrl,
|
buildAppInviteUrl,
|
||||||
@@ -78,6 +79,7 @@ function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
|
|||||||
|
|
||||||
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||||
const owner = await getUserById(server.ownerId);
|
const owner = await getUserById(server.ownerId);
|
||||||
|
const userCount = await countServerMemberships(server.id);
|
||||||
const { passwordHash, ...publicServer } = server;
|
const { passwordHash, ...publicServer } = server;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -85,7 +87,8 @@ async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
|||||||
hasPassword: server.hasPassword ?? !!passwordHash,
|
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||||
ownerName: owner?.displayName,
|
ownerName: owner?.displayName,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
userCount: server.currentUsers
|
currentUsers: userCount,
|
||||||
|
userCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
const PACKAGED_DATA_DIRECTORY_NAME = 'MetoYou Server';
|
||||||
|
|
||||||
type PackagedProcess = NodeJS.Process & { pkg?: unknown };
|
type PackagedProcess = NodeJS.Process & { pkg?: unknown };
|
||||||
|
|
||||||
function uniquePaths(paths: string[]): string[] {
|
function uniquePaths(paths: string[]): string[] {
|
||||||
@@ -21,6 +24,33 @@ export function resolveRuntimePath(...segments: string[]): string {
|
|||||||
return path.join(getRuntimeBaseDir(), ...segments);
|
return path.join(getRuntimeBaseDir(), ...segments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePackagedDataDirectory(): string {
|
||||||
|
const homeDirectory = os.homedir();
|
||||||
|
|
||||||
|
switch (process.platform) {
|
||||||
|
case 'win32':
|
||||||
|
return path.join(
|
||||||
|
process.env.APPDATA || path.join(homeDirectory, 'AppData', 'Roaming'),
|
||||||
|
PACKAGED_DATA_DIRECTORY_NAME
|
||||||
|
);
|
||||||
|
case 'darwin':
|
||||||
|
return path.join(homeDirectory, 'Library', 'Application Support', PACKAGED_DATA_DIRECTORY_NAME);
|
||||||
|
default:
|
||||||
|
return path.join(
|
||||||
|
process.env.XDG_DATA_HOME || path.join(homeDirectory, '.local', 'share'),
|
||||||
|
PACKAGED_DATA_DIRECTORY_NAME
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePersistentDataPath(...segments: string[]): string {
|
||||||
|
if (!isPackagedRuntime()) {
|
||||||
|
return resolveRuntimePath(...segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(resolvePackagedDataDirectory(), ...segments);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveProjectRootPath(...segments: string[]): string {
|
export function resolveProjectRootPath(...segments: string[]): string {
|
||||||
return path.resolve(__dirname, '..', '..', ...segments);
|
return path.resolve(__dirname, '..', '..', ...segments);
|
||||||
}
|
}
|
||||||
|
|||||||
591
server/src/services/game-matching.service.ts
Normal file
591
server/src/services/game-matching.service.ts
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
import { getRawgApiKey } from '../config/variables';
|
||||||
|
import { getDataSource } from '../db/database';
|
||||||
|
import { GameMatchMissEntity } from '../entities';
|
||||||
|
|
||||||
|
export interface MatchedGame {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
iconUrl?: string;
|
||||||
|
store?: GameStoreLink;
|
||||||
|
processName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameStoreLink {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
domain?: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
expiresAt: number;
|
||||||
|
game: Omit<MatchedGame, 'processName'> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawgSearchResponse {
|
||||||
|
results?: RawgGameResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawgGameResult {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
background_image?: string | null;
|
||||||
|
slug?: string;
|
||||||
|
stores?: RawgStoreEntry[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawgStoreEntry {
|
||||||
|
url?: string | null;
|
||||||
|
store?: RawgStore | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawgStore {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
domain?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CandidateProcess {
|
||||||
|
processName: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameMatchResult {
|
||||||
|
games: MatchedGame[];
|
||||||
|
rateLimited?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawgLookupBudget {
|
||||||
|
used: number;
|
||||||
|
windowStartedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const PERSISTED_MISS_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const RAWG_LOOKUP_WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
const RAWG_SEARCH_TIMEOUT_MS = 4_000;
|
||||||
|
const MAX_INCOMING_PROCESSES = 256;
|
||||||
|
const MAX_CANDIDATE_PROCESSES = 24;
|
||||||
|
const MAX_UNCACHED_LOOKUPS_PER_REQUEST = 4;
|
||||||
|
const MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW = 8;
|
||||||
|
const RAWG_SEARCH_URL = 'https://api.rawg.io/api/games';
|
||||||
|
const MIN_SEARCH_QUERY_LENGTH = 4;
|
||||||
|
const IGNORED_PROCESS_NAMES = new Set([
|
||||||
|
'agent',
|
||||||
|
'bash',
|
||||||
|
'baloorunner',
|
||||||
|
'chrome',
|
||||||
|
'code',
|
||||||
|
'conhost',
|
||||||
|
'cursor',
|
||||||
|
'csrss',
|
||||||
|
'dbus-daemon',
|
||||||
|
'discord',
|
||||||
|
'dwm',
|
||||||
|
'electron',
|
||||||
|
'explorer',
|
||||||
|
'firefox',
|
||||||
|
'gameoverlayui',
|
||||||
|
'gamemoded',
|
||||||
|
'gamescopereaper',
|
||||||
|
'gnome-shell',
|
||||||
|
'init',
|
||||||
|
'kernel_task',
|
||||||
|
'metoyou',
|
||||||
|
'nvidia-settings',
|
||||||
|
'node',
|
||||||
|
'npm',
|
||||||
|
'obs',
|
||||||
|
'powershell',
|
||||||
|
'pulseaudio',
|
||||||
|
'services',
|
||||||
|
'steam',
|
||||||
|
'steamwebhelper',
|
||||||
|
'system',
|
||||||
|
'systemd',
|
||||||
|
'taskhostw',
|
||||||
|
'wininit',
|
||||||
|
'winlogon',
|
||||||
|
'xorg'
|
||||||
|
]);
|
||||||
|
const IGNORED_PROCESS_PATTERNS = [
|
||||||
|
new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'),
|
||||||
|
new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'),
|
||||||
|
new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'),
|
||||||
|
new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'),
|
||||||
|
/^(appimage|at-spi|baloo|dconf|gvfs|ibus|kde|kworker)/,
|
||||||
|
/^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/,
|
||||||
|
/(helper|service|daemon|runner|tracker|portal|updater|worker)$/
|
||||||
|
];
|
||||||
|
const STORE_SEARCH_URL_BUILDERS: Record<string, (query: string) => string> = {
|
||||||
|
steam: (query) => `https://store.steampowered.com/search/?term=${query}`,
|
||||||
|
'epic-games': (query) => `https://store.epicgames.com/en-US/browse?q=${query}`,
|
||||||
|
gog: (query) => `https://www.gog.com/en/games?query=${query}`,
|
||||||
|
itch: (query) => `https://itch.io/search?q=${query}`,
|
||||||
|
'xbox-store': (query) => `https://www.xbox.com/search?q=${query}`,
|
||||||
|
'playstation-store': (query) => `https://store.playstation.com/search/${query}`,
|
||||||
|
nintendo: (query) => `https://www.nintendo.com/search/#q=${query}`,
|
||||||
|
'apple-appstore': (query) => `https://apps.apple.com/us/search?term=${query}`,
|
||||||
|
'google-play': (query) => `https://play.google.com/store/search?q=${query}&c=apps`
|
||||||
|
};
|
||||||
|
const STORE_SEARCH_ALIASES = new Map<string, string>([
|
||||||
|
['steam', 'steam'],
|
||||||
|
['store.steampowered.com', 'steam'],
|
||||||
|
['epic-games', 'epic-games'],
|
||||||
|
['store.epicgames.com', 'epic-games'],
|
||||||
|
['gog', 'gog'],
|
||||||
|
['www.gog.com', 'gog'],
|
||||||
|
['gog.com', 'gog'],
|
||||||
|
['itch', 'itch'],
|
||||||
|
['itch.io', 'itch'],
|
||||||
|
['xbox-store', 'xbox-store'],
|
||||||
|
['www.xbox.com', 'xbox-store'],
|
||||||
|
['xbox.com', 'xbox-store'],
|
||||||
|
['playstation-store', 'playstation-store'],
|
||||||
|
['store.playstation.com', 'playstation-store'],
|
||||||
|
['nintendo', 'nintendo'],
|
||||||
|
['www.nintendo.com', 'nintendo'],
|
||||||
|
['nintendo.com', 'nintendo'],
|
||||||
|
['apple-appstore', 'apple-appstore'],
|
||||||
|
['apps.apple.com', 'apple-appstore'],
|
||||||
|
['google-play', 'google-play'],
|
||||||
|
['play.google.com', 'google-play']
|
||||||
|
]);
|
||||||
|
const STORE_PRIORITY = new Map<string, number>([
|
||||||
|
['steam', 0],
|
||||||
|
['gog', 10],
|
||||||
|
['epic-games', 20],
|
||||||
|
['itch', 30],
|
||||||
|
['xbox-store', 80],
|
||||||
|
['playstation-store', 90]
|
||||||
|
]);
|
||||||
|
const cache = new Map<string, CacheEntry>();
|
||||||
|
const rawgLookupBudgets = new Map<string, RawgLookupBudget>();
|
||||||
|
|
||||||
|
export async function matchRunningGames(
|
||||||
|
processNames: unknown,
|
||||||
|
requester: unknown = 'anonymous'
|
||||||
|
): Promise<GameMatchResult> {
|
||||||
|
const candidates = normalizeProcessList(processNames).slice(0, MAX_CANDIDATE_PROCESSES);
|
||||||
|
const matches: MatchedGame[] = [];
|
||||||
|
const seenGameIds = new Set<string>();
|
||||||
|
const requesterKey = normalizeRequesterKey(requester);
|
||||||
|
const persistedMisses = await loadPersistedMissKeys(candidates.map((candidate) => candidate.processName));
|
||||||
|
|
||||||
|
let uncachedLookups = 0;
|
||||||
|
let rateLimited = false;
|
||||||
|
|
||||||
|
for (const { processName } of candidates) {
|
||||||
|
const cacheKey = normalizeCacheKey(processName);
|
||||||
|
const cached = getCachedGame(cacheKey);
|
||||||
|
|
||||||
|
if (cached !== undefined) {
|
||||||
|
appendMatch(matches, seenGameIds, processName, cached);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (persistedMisses.has(cacheKey)) {
|
||||||
|
setCachedGame(cacheKey, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uncachedLookups >= MAX_UNCACHED_LOOKUPS_PER_REQUEST) {
|
||||||
|
rateLimited = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tryConsumeRawgLookup(requesterKey)) {
|
||||||
|
rateLimited = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
uncachedLookups += 1;
|
||||||
|
|
||||||
|
const game = await resolveRawgGame(processName);
|
||||||
|
|
||||||
|
setCachedGame(cacheKey, game);
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
await rememberPersistedMiss(cacheKey, processName);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendMatch(matches, seenGameIds, processName, game);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
games: matches,
|
||||||
|
rateLimited: rateLimited || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcessList(value: unknown): CandidateProcess[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const processes = new Map<string, CandidateProcess>();
|
||||||
|
|
||||||
|
for (const entry of value.slice(0, MAX_INCOMING_PROCESSES)) {
|
||||||
|
const processName = normalizeProcessName(entry);
|
||||||
|
|
||||||
|
if (processName) {
|
||||||
|
const cacheKey = normalizeCacheKey(processName);
|
||||||
|
|
||||||
|
if (!processes.has(cacheKey)) {
|
||||||
|
processes.set(cacheKey, {
|
||||||
|
processName,
|
||||||
|
score: scoreCandidateProcess(String(entry), processName)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(processes.values())
|
||||||
|
.sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcessName(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value
|
||||||
|
.trim()
|
||||||
|
.replace(/\.exe$/i, '')
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const cacheKey = normalizeCacheKey(normalized);
|
||||||
|
|
||||||
|
if (normalized.length < 3 || normalized.length > 96 || shouldIgnoreProcessName(cacheKey)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIgnoreProcessName(cacheKey: string): boolean {
|
||||||
|
return IGNORED_PROCESS_NAMES.has(cacheKey)
|
||||||
|
|| IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequesterKey(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return 'anonymous';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
return normalized || 'anonymous';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryConsumeRawgLookup(requesterKey: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = rawgLookupBudgets.get(requesterKey);
|
||||||
|
|
||||||
|
if (!existing || existing.windowStartedAt + RAWG_LOOKUP_WINDOW_MS <= now) {
|
||||||
|
rawgLookupBudgets.set(requesterKey, {
|
||||||
|
used: 1,
|
||||||
|
windowStartedAt: now
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.used >= MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.used += 1;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreCandidateProcess(rawValue: string, processName: string): number {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (/\.exe$/i.test(rawValue.trim())) {
|
||||||
|
score += 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[A-Z]/.test(processName) && /[a-z]/.test(processName)) {
|
||||||
|
score += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\d/.test(processName)) {
|
||||||
|
score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processName.length >= 5 && processName.length <= 32) {
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processName.includes(' ')) {
|
||||||
|
score -= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCacheKey(value: string): string {
|
||||||
|
return value.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedGame(cacheKey: string): Omit<MatchedGame, 'processName'> | null | undefined {
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
|
||||||
|
if (!cached) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached.expiresAt <= Date.now()) {
|
||||||
|
cache.delete(cacheKey);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached.game;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedGame(cacheKey: string, game: Omit<MatchedGame, 'processName'> | null): void {
|
||||||
|
cache.set(cacheKey, {
|
||||||
|
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||||
|
game
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPersistedMissKeys(processNames: string[]): Promise<Set<string>> {
|
||||||
|
const cacheKeys = Array.from(new Set(processNames.map((name) => normalizeCacheKey(name))));
|
||||||
|
|
||||||
|
if (cacheKeys.length === 0) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repository = getDataSource().getRepository(GameMatchMissEntity);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
await repository.createQueryBuilder()
|
||||||
|
.delete()
|
||||||
|
.where('expiresAt <= :now', { now })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const rows = await repository.createQueryBuilder('miss')
|
||||||
|
.select('miss.processKey')
|
||||||
|
.where('miss.processKey IN (:...cacheKeys)', { cacheKeys })
|
||||||
|
.andWhere('miss.expiresAt > :now', { now })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return new Set(rows.map((row) => row.processKey));
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rememberPersistedMiss(cacheKey: string, processName: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
await getDataSource().getRepository(GameMatchMissEntity)
|
||||||
|
.save({
|
||||||
|
processKey: cacheKey,
|
||||||
|
processName,
|
||||||
|
missedAt: now,
|
||||||
|
expiresAt: now + PERSISTED_MISS_TTL_MS
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRawgGame(processName: string): Promise<Omit<MatchedGame, 'processName'> | null> {
|
||||||
|
const apiKey = getRawgApiKey();
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = buildSearchQuery(processName);
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(RAWG_SEARCH_URL);
|
||||||
|
|
||||||
|
url.searchParams.set('key', apiKey);
|
||||||
|
url.searchParams.set('search', query);
|
||||||
|
url.searchParams.set('search_precise', 'true');
|
||||||
|
url.searchParams.set('exclude_additions', 'true');
|
||||||
|
url.searchParams.set('page_size', '1');
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), RAWG_SEARCH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: controller.signal });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await response.json() as RawgSearchResponse;
|
||||||
|
const result = body.results?.[0];
|
||||||
|
|
||||||
|
if (!isAcceptableRawgMatch(query, result)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(result.id),
|
||||||
|
name: result.name.trim(),
|
||||||
|
iconUrl: result.background_image || undefined,
|
||||||
|
store: selectPreferredStore(result, result.name.trim())
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPreferredStore(result: RawgGameResult, gameName: string): GameStoreLink | undefined {
|
||||||
|
const stores = Array.isArray(result.stores) ? result.stores : [];
|
||||||
|
const usableStores = stores
|
||||||
|
.map((entry) => buildStoreLink(entry, gameName))
|
||||||
|
.filter((store): store is GameStoreLink => !!store);
|
||||||
|
|
||||||
|
return usableStores.sort((left, right) => getStorePriority(left) - getStorePriority(right))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStorePriority(store: GameStoreLink): number {
|
||||||
|
const storeKey = STORE_SEARCH_ALIASES.get(store.slug ?? '')
|
||||||
|
?? STORE_SEARCH_ALIASES.get(store.domain ?? '')
|
||||||
|
?? store.name.trim().toLowerCase();
|
||||||
|
|
||||||
|
return STORE_PRIORITY.get(storeKey) ?? 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoreLink(entry: RawgStoreEntry, gameName: string): GameStoreLink | undefined {
|
||||||
|
const store = entry.store;
|
||||||
|
|
||||||
|
if (!store || typeof store.name !== 'string' || !store.name.trim()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = typeof store.slug === 'string' && store.slug.trim()
|
||||||
|
? store.slug.trim().toLowerCase()
|
||||||
|
: undefined;
|
||||||
|
const domain = typeof store.domain === 'string' && store.domain.trim()
|
||||||
|
? store.domain.trim()
|
||||||
|
.replace(/^https?:\/\//i, '')
|
||||||
|
.replace(/\/$/, '')
|
||||||
|
: undefined;
|
||||||
|
const url = normalizeExternalUrl(entry.url) ?? buildStoreSearchUrl(slug, domain, gameName);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof store.id === 'number' ? String(store.id) : undefined,
|
||||||
|
name: store.name.trim(),
|
||||||
|
slug,
|
||||||
|
domain,
|
||||||
|
url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalUrl(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
return trimmed.startsWith('http://') || trimmed.startsWith('https://')
|
||||||
|
? trimmed
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoreSearchUrl(slug: string | undefined, domain: string | undefined, gameName: string): string | undefined {
|
||||||
|
const query = encodeURIComponent(gameName);
|
||||||
|
const storeKey = STORE_SEARCH_ALIASES.get(slug ?? '') ?? STORE_SEARCH_ALIASES.get(domain ?? '');
|
||||||
|
const buildUrl = storeKey ? STORE_SEARCH_URL_BUILDERS[storeKey] : undefined;
|
||||||
|
|
||||||
|
return buildUrl?.(query) ?? (domain ? `https://${domain}` : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchQuery(processName: string): string {
|
||||||
|
const query = processName
|
||||||
|
.replace(/\.exe$/i, '')
|
||||||
|
.replace(/\b(x64|x86|win64|win32|linux|shipping|client|launcher|game)\b/gi, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return query.length >= MIN_SEARCH_QUERY_LENGTH ? query : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAcceptableRawgMatch(
|
||||||
|
query: string,
|
||||||
|
result: RawgGameResult | undefined
|
||||||
|
): result is Required<Pick<RawgGameResult, 'id' | 'name'>> & RawgGameResult {
|
||||||
|
if (!result || typeof result.id !== 'number' || typeof result.name !== 'string' || !result.name.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryKey = normalizeComparableText(query);
|
||||||
|
const nameKey = normalizeComparableText(result.name);
|
||||||
|
const slugKey = normalizeComparableText(result.slug ?? '');
|
||||||
|
const queryTokens = tokenizeComparableText(queryKey);
|
||||||
|
const nameTokens = tokenizeComparableText(nameKey);
|
||||||
|
const slugTokens = tokenizeComparableText(slugKey);
|
||||||
|
|
||||||
|
if (queryKey.length < MIN_SEARCH_QUERY_LENGTH || queryTokens.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryKey === nameKey || queryKey === slugKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryTokens.length === 1) {
|
||||||
|
const [queryToken] = queryTokens;
|
||||||
|
|
||||||
|
return queryToken.length >= 5
|
||||||
|
&& (nameTokens.includes(queryToken) || slugTokens.includes(queryToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryTokens.every((token) => nameTokens.includes(token) || slugTokens.includes(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeComparableText(value: string): string {
|
||||||
|
return value.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeComparableText(value: string): string[] {
|
||||||
|
return value.split(' ')
|
||||||
|
.filter((token) => token.length >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMatch(
|
||||||
|
matches: MatchedGame[],
|
||||||
|
seenGameIds: Set<string>,
|
||||||
|
processName: string,
|
||||||
|
game: Omit<MatchedGame, 'processName'> | null
|
||||||
|
): void {
|
||||||
|
if (!game || seenGameIds.has(game.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenGameIds.add(game.id);
|
||||||
|
matches.push({
|
||||||
|
...game,
|
||||||
|
processName
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -130,6 +130,10 @@ export async function findServerMembership(serverId: string, userId: string): Pr
|
|||||||
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function countServerMemberships(serverId: string): Promise<number> {
|
||||||
|
return await getMembershipRepository().count({ where: { serverId } });
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
||||||
const repo = getMembershipRepository();
|
const repo = getMembershipRepository();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
303
server/src/websocket/handler-status.spec.ts
Normal file
303
server/src/websocket/handler-status.spec.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
beforeEach,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import { connectedUsers } from './state';
|
||||||
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
import { ConnectedUser } from './types';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
vi.mock('../services/server-access.service', () => ({
|
||||||
|
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const }))
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal mock WebSocket that records sent messages.
|
||||||
|
*/
|
||||||
|
function createMockWs(): WebSocket & { sentMessages: string[] } {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const ws = {
|
||||||
|
readyState: WebSocket.OPEN,
|
||||||
|
send: (data: string) => { sent.push(data); },
|
||||||
|
close: () => {},
|
||||||
|
sentMessages: sent
|
||||||
|
} as unknown as WebSocket & { sentMessages: string[] };
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConnectedUser(
|
||||||
|
connectionId: string,
|
||||||
|
oderId: string,
|
||||||
|
overrides: Partial<ConnectedUser> = {}
|
||||||
|
): ConnectedUser {
|
||||||
|
const ws = createMockWs();
|
||||||
|
const user: ConnectedUser = {
|
||||||
|
oderId,
|
||||||
|
ws,
|
||||||
|
serverIds: new Set(),
|
||||||
|
displayName: 'Test User',
|
||||||
|
lastPong: Date.now(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredConnectedUser(connectionId: string): ConnectedUser {
|
||||||
|
const connectedUser = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
|
if (!connectedUser)
|
||||||
|
throw new Error(`Expected connected user for ${connectionId}`);
|
||||||
|
|
||||||
|
return connectedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSentMessagesStore(user: ConnectedUser): { sentMessages: string[] } {
|
||||||
|
return user.ws as unknown as { sentMessages: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('server websocket handler - status_update', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats signaling keepalive messages as connection liveness', async () => {
|
||||||
|
createConnectedUser('conn-1', 'user-1', { lastPong: 1 });
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'keepalive' });
|
||||||
|
|
||||||
|
expect(connectedUsers.get('conn-1')?.lastPong).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates user status on valid status_update message', async () => {
|
||||||
|
const user = createConnectedUser('conn-1', 'user-1');
|
||||||
|
|
||||||
|
user.serverIds.add('server-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||||
|
|
||||||
|
expect(connectedUsers.get('conn-1')?.status).toBe('away');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcasts status_update to other users in the same server', async () => {
|
||||||
|
const user1 = createConnectedUser('conn-1', 'user-1');
|
||||||
|
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|
||||||
|
user1.serverIds.add('server-1');
|
||||||
|
user2.serverIds.add('server-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'busy' });
|
||||||
|
|
||||||
|
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
|
const statusMsg = messages.find((message: { type: string }) => message.type === 'status_update');
|
||||||
|
|
||||||
|
expect(statusMsg).toBeDefined();
|
||||||
|
expect(statusMsg?.oderId).toBe('user-1');
|
||||||
|
expect(statusMsg?.status).toBe('busy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not broadcast to users in different servers', async () => {
|
||||||
|
createConnectedUser('conn-1', 'user-1');
|
||||||
|
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|
||||||
|
getRequiredConnectedUser('conn-1').serverIds.add('server-1');
|
||||||
|
user2.serverIds.add('server-2');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||||
|
|
||||||
|
expect(getSentMessagesStore(user2).sentMessages.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid status values', async () => {
|
||||||
|
createConnectedUser('conn-1', 'user-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'invalid_status' });
|
||||||
|
|
||||||
|
expect(connectedUsers.get('conn-1')?.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores missing status field', async () => {
|
||||||
|
createConnectedUser('conn-1', 'user-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update' });
|
||||||
|
|
||||||
|
expect(connectedUsers.get('conn-1')?.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts all valid status values', async () => {
|
||||||
|
for (const status of [
|
||||||
|
'online',
|
||||||
|
'away',
|
||||||
|
'busy',
|
||||||
|
'offline'
|
||||||
|
]) {
|
||||||
|
connectedUsers.clear();
|
||||||
|
createConnectedUser('conn-1', 'user-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status });
|
||||||
|
|
||||||
|
expect(connectedUsers.get('conn-1')?.status).toBe(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes status in server_users response after status change', async () => {
|
||||||
|
const user1 = createConnectedUser('conn-1', 'user-1');
|
||||||
|
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|
||||||
|
user1.serverIds.add('server-1');
|
||||||
|
user2.serverIds.add('server-1');
|
||||||
|
|
||||||
|
// Set user-1 to away
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||||
|
|
||||||
|
// Clear sent messages
|
||||||
|
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||||
|
|
||||||
|
// Identify first (required for handler)
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
||||||
|
|
||||||
|
// user-2 joins server -> should receive server_users with user-1's status
|
||||||
|
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||||
|
await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' });
|
||||||
|
|
||||||
|
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
|
const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users');
|
||||||
|
|
||||||
|
expect(serverUsersMsg).toBeDefined();
|
||||||
|
|
||||||
|
const user1InList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1');
|
||||||
|
|
||||||
|
expect(user1InList?.status).toBe('away');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('server websocket handler - user_joined includes status', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes status in user_joined broadcast', async () => {
|
||||||
|
const user1 = createConnectedUser('conn-1', 'user-1');
|
||||||
|
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|
||||||
|
user1.serverIds.add('server-1');
|
||||||
|
user2.serverIds.add('server-1');
|
||||||
|
|
||||||
|
// Set user-1's status to busy before joining
|
||||||
|
getRequiredConnectedUser('conn-1').status = 'busy';
|
||||||
|
|
||||||
|
// Identify user-1
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
||||||
|
|
||||||
|
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||||
|
|
||||||
|
// user-1 joins server-1
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' });
|
||||||
|
|
||||||
|
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
|
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
|
||||||
|
|
||||||
|
// user_joined may or may not appear depending on whether it's a new identity membership
|
||||||
|
// Since both are already in the server, it may not broadcast. Either way, verify no crash.
|
||||||
|
if (joinMsg) {
|
||||||
|
expect(joinMsg.status).toBe('busy');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('server websocket handler - profile metadata in presence messages', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcasts updated profile metadata when an identified user changes it', async () => {
|
||||||
|
const alice = createConnectedUser('conn-1', 'user-1', {
|
||||||
|
displayName: 'Alice',
|
||||||
|
viewedServerId: 'server-1'
|
||||||
|
});
|
||||||
|
const bob = createConnectedUser('conn-2', 'user-2', {
|
||||||
|
viewedServerId: 'server-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
alice.serverIds.add('server-1');
|
||||||
|
bob.serverIds.add('server-1');
|
||||||
|
getSentMessagesStore(bob).sentMessages.length = 0;
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'identify',
|
||||||
|
oderId: 'user-1',
|
||||||
|
displayName: 'Alice Updated',
|
||||||
|
description: 'Updated bio',
|
||||||
|
profileUpdatedAt: 789
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
|
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
|
||||||
|
|
||||||
|
expect(joinMsg?.displayName).toBe('Alice Updated');
|
||||||
|
expect(joinMsg?.description).toBe('Updated bio');
|
||||||
|
expect(joinMsg?.profileUpdatedAt).toBe(789);
|
||||||
|
expect(joinMsg?.serverId).toBe('server-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes description and profileUpdatedAt in server_users responses', async () => {
|
||||||
|
const alice = createConnectedUser('conn-1', 'user-1');
|
||||||
|
const bob = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|
||||||
|
alice.serverIds.add('server-1');
|
||||||
|
bob.serverIds.add('server-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'identify',
|
||||||
|
oderId: 'user-1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
description: 'Alice bio',
|
||||||
|
profileUpdatedAt: 123
|
||||||
|
});
|
||||||
|
|
||||||
|
getSentMessagesStore(bob).sentMessages.length = 0;
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-2', {
|
||||||
|
type: 'view_server',
|
||||||
|
serverId: 'server-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
|
const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users');
|
||||||
|
const aliceInList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1');
|
||||||
|
|
||||||
|
expect(aliceInList?.description).toBe('Alice bio');
|
||||||
|
expect(aliceInList?.profileUpdatedAt).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes description and profileUpdatedAt in user_joined broadcasts', async () => {
|
||||||
|
const bob = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|
||||||
|
bob.serverIds.add('server-1');
|
||||||
|
bob.viewedServerId = 'server-1';
|
||||||
|
|
||||||
|
createConnectedUser('conn-1', 'user-1', {
|
||||||
|
displayName: 'Alice',
|
||||||
|
description: 'Alice bio',
|
||||||
|
profileUpdatedAt: 456,
|
||||||
|
viewedServerId: 'server-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'join_server',
|
||||||
|
serverId: 'server-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||||
|
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
|
||||||
|
|
||||||
|
expect(joinMsg?.description).toBe('Alice bio');
|
||||||
|
expect(joinMsg?.profileUpdatedAt).toBe(456);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,22 @@ function normalizeDisplayName(value: unknown, fallback = 'User'): string {
|
|||||||
return normalized || fallback;
|
return normalized || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDescription(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProfileUpdatedAt(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function readMessageId(value: unknown): string | undefined {
|
function readMessageId(value: unknown): string | undefined {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -37,7 +53,13 @@ function readMessageId(value: unknown): string | undefined {
|
|||||||
/** Sends the current user list for a given server to a single connected user. */
|
/** Sends the current user list for a given server to a single connected user. */
|
||||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||||
const users = getUniqueUsersInServer(serverId, user.oderId)
|
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||||
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
.map(cu => ({
|
||||||
|
oderId: cu.oderId,
|
||||||
|
displayName: normalizeDisplayName(cu.displayName),
|
||||||
|
description: cu.description,
|
||||||
|
profileUpdatedAt: cu.profileUpdatedAt,
|
||||||
|
status: cu.status ?? 'online'
|
||||||
|
}));
|
||||||
|
|
||||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||||
}
|
}
|
||||||
@@ -45,31 +67,44 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
|||||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
||||||
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||||
|
const previousDisplayName = normalizeDisplayName(user.displayName);
|
||||||
// Close stale connections from the same identity AND the same connection
|
const previousDescription = user.description;
|
||||||
// scope so offer routing always targets the freshest socket (e.g. after
|
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
||||||
// page refresh). Connections with a *different* scope (= a different
|
|
||||||
// signal URL that happens to route to this server) are left untouched so
|
|
||||||
// multi-signal-URL setups don't trigger an eviction loop.
|
|
||||||
connectedUsers.forEach((existing, existingId) => {
|
|
||||||
if (existingId !== connectionId
|
|
||||||
&& existing.oderId === newOderId
|
|
||||||
&& existing.connectionScope === newScope) {
|
|
||||||
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId}, scope=${newScope ?? 'none'})`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
existing.ws.close();
|
|
||||||
} catch { /* already closing */ }
|
|
||||||
|
|
||||||
connectedUsers.delete(existingId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
user.oderId = newOderId;
|
user.oderId = newOderId;
|
||||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
|
||||||
|
user.description = normalizeDescription(message['description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(message, 'profileUpdatedAt')) {
|
||||||
|
user.profileUpdatedAt = normalizeProfileUpdatedAt(message['profileUpdatedAt']);
|
||||||
|
}
|
||||||
|
|
||||||
user.connectionScope = newScope;
|
user.connectionScope = newScope;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||||
|
|
||||||
|
if (
|
||||||
|
user.displayName === previousDisplayName
|
||||||
|
&& user.description === previousDescription
|
||||||
|
&& user.profileUpdatedAt === previousProfileUpdatedAt
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const serverId of user.serverIds) {
|
||||||
|
broadcastToServer(serverId, {
|
||||||
|
type: 'user_joined',
|
||||||
|
oderId: user.oderId,
|
||||||
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
|
description: user.description,
|
||||||
|
profileUpdatedAt: user.profileUpdatedAt,
|
||||||
|
status: user.status ?? 'online',
|
||||||
|
serverId
|
||||||
|
}, user.oderId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
@@ -108,6 +143,9 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
|||||||
type: 'user_joined',
|
type: 'user_joined',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: normalizeDisplayName(user.displayName),
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
|
description: user.description,
|
||||||
|
profileUpdatedAt: user.profileUpdatedAt,
|
||||||
|
status: user.status ?? 'online',
|
||||||
serverId: sid
|
serverId: sid
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
@@ -204,13 +242,45 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_STATUSES = new Set([
|
||||||
|
'online',
|
||||||
|
'away',
|
||||||
|
'busy',
|
||||||
|
'offline'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
|
const status = typeof message['status'] === 'string' ? message['status'] : undefined;
|
||||||
|
|
||||||
|
if (!status || !VALID_STATUSES.has(status))
|
||||||
|
return;
|
||||||
|
|
||||||
|
user.status = status as ConnectedUser['status'];
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status -> ${status}`);
|
||||||
|
|
||||||
|
for (const serverId of user.serverIds) {
|
||||||
|
broadcastToServer(serverId, {
|
||||||
|
type: 'status_update',
|
||||||
|
oderId: user.oderId,
|
||||||
|
status
|
||||||
|
}, user.oderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||||
const user = connectedUsers.get(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
if (!user)
|
if (!user)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
user.lastPong = Date.now();
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
|
case 'keepalive':
|
||||||
|
break;
|
||||||
|
|
||||||
case 'identify':
|
case 'identify':
|
||||||
handleIdentify(user, message, connectionId);
|
handleIdentify(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
@@ -241,6 +311,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
handleTyping(user, message);
|
handleTyping(user, message);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'status_update':
|
||||||
|
handleStatusUpdate(user, message, connectionId);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log('Unknown message type:', message.type);
|
console.log('Unknown message type:', message.type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface ConnectedUser {
|
|||||||
serverIds: Set<string>;
|
serverIds: Set<string>;
|
||||||
viewedServerId?: string;
|
viewedServerId?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
/**
|
/**
|
||||||
* Opaque scope string sent by the client (typically the signal URL it
|
* Opaque scope string sent by the client (typically the signal URL it
|
||||||
* connected through). Stale-connection eviction only targets connections
|
* connected through). Stale-connection eviction only targets connections
|
||||||
@@ -13,6 +15,8 @@ export interface ConnectedUser {
|
|||||||
* URLs routing to the same server coexist without an eviction loop.
|
* URLs routing to the same server coexist without an eviction loop.
|
||||||
*/
|
*/
|
||||||
connectionScope?: string;
|
connectionScope?: string;
|
||||||
/** Timestamp of the last pong received (used to detect dead connections). */
|
/** User availability status (online, away, busy, offline). */
|
||||||
|
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||||
|
/** Timestamp of the last pong or client message received (used to detect dead connections). */
|
||||||
lastPong: number;
|
lastPong: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,5 @@
|
|||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
42
toju-app/README.md
Normal file
42
toju-app/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Product Client
|
||||||
|
|
||||||
|
Angular 21 renderer for MetoYou / Toju. This package is managed from the repository root, so the main build, test, lint, and Electron integration commands are run there rather than from a local `package.json`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `npm run start` starts the Angular dev server.
|
||||||
|
- `npm run build` builds the client to `dist/client`.
|
||||||
|
- `npm run watch` runs the Angular build in watch mode.
|
||||||
|
- `npm run test` runs the product-client Vitest suite.
|
||||||
|
- `npm run lint` runs ESLint across the repo.
|
||||||
|
- `npm run format` formats Angular HTML templates.
|
||||||
|
- `npm run sort:props` sorts Angular template properties.
|
||||||
|
- `npm run electron:dev` or `npm run dev` runs the client with Electron.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/app/domains/` | Bounded contexts and public domain entry points |
|
||||||
|
| `src/app/infrastructure/` | Shared technical runtime such as persistence and realtime |
|
||||||
|
| `src/app/shared-kernel/` | Cross-domain contracts and shared models |
|
||||||
|
| `src/app/features/` | App-level composition and transitional feature shells |
|
||||||
|
| `src/app/core/` | Platform adapters, compatibility entry points, and cross-domain technical helpers |
|
||||||
|
| `src/app/shared/` | Shared UI primitives and utilities |
|
||||||
|
| `src/app/store/` | NgRx reducers, effects, selectors, and actions |
|
||||||
|
| `public/` | Static assets copied into the Angular build |
|
||||||
|
|
||||||
|
## Key Docs
|
||||||
|
|
||||||
|
- [src/app/domains/README.md](src/app/domains/README.md)
|
||||||
|
- [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md)
|
||||||
|
- [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md)
|
||||||
|
- [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md)
|
||||||
|
- [../docs/architecture.md](../docs/architecture.md)
|
||||||
|
- [AGENTS.md](AGENTS.md)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `angular.json` defines build, serve, and lint targets for the product client.
|
||||||
|
- Product-client tests currently run through the root Vitest setup instead of an Angular `test` architect target.
|
||||||
|
- If the renderer-to-desktop contract changes, update the Angular bridge, Electron preload API, and IPC handlers together.
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "2.2MB",
|
"maximumWarning": "2.2MB",
|
||||||
"maximumError": "2.3MB"
|
"maximumError": "2.38MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { roomsReducer } from './store/rooms/rooms.reducer';
|
|||||||
import { NotificationsEffects } from './domains/notifications';
|
import { NotificationsEffects } from './domains/notifications';
|
||||||
import { MessagesEffects } from './store/messages/messages.effects';
|
import { MessagesEffects } from './store/messages/messages.effects';
|
||||||
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
||||||
|
import { UserAvatarEffects } from './store/users/user-avatar.effects';
|
||||||
import { UsersEffects } from './store/users/users.effects';
|
import { UsersEffects } from './store/users/users.effects';
|
||||||
import { RoomsEffects } from './store/rooms/rooms.effects';
|
import { RoomsEffects } from './store/rooms/rooms.effects';
|
||||||
import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects';
|
import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects';
|
||||||
@@ -38,6 +39,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
NotificationsEffects,
|
NotificationsEffects,
|
||||||
MessagesEffects,
|
MessagesEffects,
|
||||||
MessagesSyncEffects,
|
MessagesSyncEffects,
|
||||||
|
UserAvatarEffects,
|
||||||
UsersEffects,
|
UsersEffects,
|
||||||
RoomsEffects,
|
RoomsEffects,
|
||||||
RoomMembersSyncEffects,
|
RoomMembersSyncEffects,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
@if (themeStudioFullscreenComponent()) {
|
@if (themeStudioFullscreenComponent()) {
|
||||||
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()" />
|
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()" />
|
||||||
} @else {
|
} @else {
|
||||||
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio…</div>
|
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio...</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else { @if (showDesktopUpdateNotice()) {
|
} @else { @if (showDesktopUpdateNotice()) {
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @if (!isThemeStudioFullscreen()) {
|
} @if (!isThemeStudioFullscreen() && !isDirectMessageRoute()) {
|
||||||
<app-floating-voice-controls />
|
<app-floating-voice-controls />
|
||||||
}
|
}
|
||||||
<app-settings-modal />
|
<app-settings-modal />
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent)
|
import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'dm',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dm/:conversationId',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
@@ -33,17 +33,20 @@ import { VoiceSessionFacade } from './domains/voice-session';
|
|||||||
import { ExternalLinkService } from './core/platform';
|
import { ExternalLinkService } from './core/platform';
|
||||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
import { SettingsModalService } from './core/services/settings-modal.service';
|
||||||
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
||||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
import { UserStatusService } from './core/services/user-status.service';
|
||||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
import { GameActivityService } from './domains/game-activity';
|
||||||
|
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
|
||||||
|
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
|
||||||
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
||||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||||
import { NativeContextMenuComponent } from './features/shell/native-context-menu.component';
|
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
|
||||||
import { UsersActions } from './store/users/users.actions';
|
import { UsersActions } from './store/users/users.actions';
|
||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID } from './core/constants';
|
import { ROOM_URL_PATTERN } from './core/constants';
|
||||||
|
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
|
||||||
import {
|
import {
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
ThemePickerOverlayComponent,
|
ThemePickerOverlayComponent,
|
||||||
@@ -92,10 +95,13 @@ export class App implements OnInit, OnDestroy {
|
|||||||
readonly voiceSession = inject(VoiceSessionFacade);
|
readonly voiceSession = inject(VoiceSessionFacade);
|
||||||
readonly externalLinks = inject(ExternalLinkService);
|
readonly externalLinks = inject(ExternalLinkService);
|
||||||
readonly electronBridge = inject(ElectronBridgeService);
|
readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
readonly userStatus = inject(UserStatusService);
|
||||||
|
readonly gameActivity = inject(GameActivityService);
|
||||||
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
|
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
|
||||||
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
||||||
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
||||||
readonly isDraggingThemeStudioControls = signal(false);
|
readonly isDraggingThemeStudioControls = signal(false);
|
||||||
|
readonly currentRouteUrl = signal(this.getCurrentRouteUrl());
|
||||||
|
|
||||||
readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell'));
|
readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell'));
|
||||||
readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail'));
|
readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail'));
|
||||||
@@ -109,6 +115,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
return this.settingsModal.activePage() === 'theme'
|
return this.settingsModal.activePage() === 'theme'
|
||||||
&& this.settingsModal.themeStudioMinimized();
|
&& this.settingsModal.themeStudioMinimized();
|
||||||
});
|
});
|
||||||
|
readonly isDirectMessageRoute = computed(() => this.getRoutePath(this.currentRouteUrl()).startsWith('/dm'));
|
||||||
readonly desktopUpdateNoticeKey = computed(() => {
|
readonly desktopUpdateNoticeKey = computed(() => {
|
||||||
const updateState = this.desktopUpdateState();
|
const updateState = this.desktopUpdateState();
|
||||||
|
|
||||||
@@ -159,7 +166,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void import('./domains/theme/feature/settings/theme-settings.component')
|
void import('./domains/theme/feature/settings/theme-settings/theme-settings.component')
|
||||||
.then((module) => {
|
.then((module) => {
|
||||||
this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent);
|
this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent);
|
||||||
});
|
});
|
||||||
@@ -217,8 +224,19 @@ export class App implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
void this.desktopUpdates.initialize();
|
void this.desktopUpdates.initialize();
|
||||||
|
|
||||||
|
let currentUserId = getStoredCurrentUserId();
|
||||||
|
|
||||||
await this.databaseService.initialize();
|
await this.databaseService.initialize();
|
||||||
|
|
||||||
|
if (currentUserId) {
|
||||||
|
const persistedUserId = await this.databaseService.getCurrentUserId();
|
||||||
|
|
||||||
|
if (persistedUserId !== currentUserId) {
|
||||||
|
clearStoredCurrentUserId();
|
||||||
|
currentUserId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiBase = this.servers.getApiBaseUrl();
|
const apiBase = this.servers.getApiBaseUrl();
|
||||||
|
|
||||||
@@ -229,29 +247,29 @@ export class App implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
await this.setupDesktopDeepLinks();
|
await this.setupDesktopDeepLinks();
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
this.userStatus.start();
|
||||||
|
this.gameActivity.start();
|
||||||
this.store.dispatch(RoomsActions.loadRooms());
|
const currentUrl = this.getCurrentRouteUrl();
|
||||||
|
|
||||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
if (!this.isPublicRoute(this.router.url)) {
|
if (!this.isPublicRoute(currentUrl)) {
|
||||||
this.router.navigate(['/login'], {
|
this.router.navigate(['/login'], {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
returnUrl: this.router.url
|
returnUrl: currentUrl
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const current = this.router.url;
|
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||||
|
this.store.dispatch(RoomsActions.loadRooms());
|
||||||
|
|
||||||
const generalSettings = loadGeneralSettingsFromStorage();
|
const generalSettings = loadGeneralSettingsFromStorage();
|
||||||
const lastViewedChat = loadLastViewedChatFromStorage(currentUserId);
|
const lastViewedChat = loadLastViewedChatFromStorage(currentUserId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
generalSettings.reopenLastViewedChat
|
generalSettings.reopenLastViewedChat
|
||||||
&& lastViewedChat
|
&& lastViewedChat
|
||||||
&& (current === '/' || current === '/search')
|
&& (currentUrl === '/' || currentUrl === '/search')
|
||||||
) {
|
) {
|
||||||
this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {});
|
this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -260,6 +278,8 @@ export class App implements OnInit, OnDestroy {
|
|||||||
this.router.events.subscribe((evt) => {
|
this.router.events.subscribe((evt) => {
|
||||||
if (evt instanceof NavigationEnd) {
|
if (evt instanceof NavigationEnd) {
|
||||||
const url = evt.urlAfterRedirects || evt.url;
|
const url = evt.urlAfterRedirects || evt.url;
|
||||||
|
|
||||||
|
this.currentRouteUrl.set(url);
|
||||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||||
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
||||||
|
|
||||||
@@ -384,9 +404,31 @@ export class App implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isPublicRoute(url: string): boolean {
|
private isPublicRoute(url: string): boolean {
|
||||||
return url === '/login' ||
|
const path = this.getRoutePath(url);
|
||||||
url === '/register' ||
|
|
||||||
url.startsWith('/invite/');
|
return path === '/login' ||
|
||||||
|
path === '/register' ||
|
||||||
|
path.startsWith('/invite/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrentRouteUrl(): string {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return this.router.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
|
||||||
|
return currentUrl || this.router.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRoutePath(url: string): string {
|
||||||
|
if (!url) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
const [path] = url.split(/[?#]/, 1);
|
||||||
|
|
||||||
|
return path || '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {
|
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
|||||||
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
|
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
|
||||||
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
|
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
|
||||||
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
|
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
|
||||||
|
export const STORAGE_KEY_ICE_SERVERS = 'metoyou_ice_servers';
|
||||||
export const STORAGE_KEY_THEME_ACTIVE = 'metoyou_theme_active';
|
export const STORAGE_KEY_THEME_ACTIVE = 'metoyou_theme_active';
|
||||||
export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft';
|
export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft';
|
||||||
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
||||||
|
|||||||
@@ -124,6 +124,24 @@ export interface SavedThemeFileDescriptor {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExportUserDataResult {
|
||||||
|
cancelled: boolean;
|
||||||
|
exported: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportUserDataResult {
|
||||||
|
backupPath?: string;
|
||||||
|
cancelled: boolean;
|
||||||
|
imported: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EraseUserDataResult {
|
||||||
|
erased: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronCommand {
|
export interface ElectronCommand {
|
||||||
type: string;
|
type: string;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
@@ -157,6 +175,7 @@ export interface ElectronApi {
|
|||||||
closeWindow: () => void;
|
closeWindow: () => void;
|
||||||
openExternal: (url: string) => Promise<boolean>;
|
openExternal: (url: string) => Promise<boolean>;
|
||||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||||
|
getRunningProcessNames: () => Promise<string[]>;
|
||||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||||
@@ -165,6 +184,10 @@ export interface ElectronApi {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
|
importUserData: () => Promise<ImportUserDataResult>;
|
||||||
|
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||||
getSavedThemesPath: () => Promise<string>;
|
getSavedThemesPath: () => Promise<string>;
|
||||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||||
readSavedTheme: (fileName: string) => Promise<string>;
|
readSavedTheme: (fileName: string) => Promise<string>;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,4 @@ export * from './notification-audio.service';
|
|||||||
export * from '../models/debugging.models';
|
export * from '../models/debugging.models';
|
||||||
export * from './debugging/debugging.service';
|
export * from './debugging/debugging.service';
|
||||||
export * from './settings-modal.service';
|
export * from './settings-modal.service';
|
||||||
|
export * from './user-status.service';
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export class NotificationAudioService {
|
|||||||
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
||||||
readonly notificationVolume = signal(this.loadVolume());
|
readonly notificationVolume = signal(this.loadVolume());
|
||||||
|
|
||||||
|
/** When true, all sound playback is suppressed (Do Not Disturb). */
|
||||||
|
readonly dndMuted = signal(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.preload();
|
this.preload();
|
||||||
}
|
}
|
||||||
@@ -106,6 +109,9 @@ export class NotificationAudioService {
|
|||||||
* the persisted {@link notificationVolume} is used.
|
* the persisted {@link notificationVolume} is used.
|
||||||
*/
|
*/
|
||||||
play(sound: AppSound, volumeOverride?: number): void {
|
play(sound: AppSound, volumeOverride?: number): void {
|
||||||
|
if (this.dndMuted())
|
||||||
|
return;
|
||||||
|
|
||||||
const cached = this.cache.get(sound);
|
const cached = this.cache.get(sound);
|
||||||
const src = this.sources.get(sound);
|
const src = this.sources.get(sound);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type SettingsPage =
|
|||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'voice'
|
| 'voice'
|
||||||
| 'updates'
|
| 'updates'
|
||||||
|
| 'data'
|
||||||
| 'debugging'
|
| 'debugging'
|
||||||
| 'server'
|
| 'server'
|
||||||
| 'members'
|
| 'members'
|
||||||
|
|||||||
181
toju-app/src/app/core/services/user-status.service.ts
Normal file
181
toju-app/src/app/core/services/user-status.service.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
OnDestroy,
|
||||||
|
NgZone,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { UsersActions } from '../../store/users/users.actions';
|
||||||
|
import { selectManualStatus, selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
|
import { RealtimeSessionFacade } from '../realtime';
|
||||||
|
import { NotificationAudioService } from './notification-audio.service';
|
||||||
|
import { UserStatus } from '../../shared-kernel';
|
||||||
|
|
||||||
|
const BROWSER_IDLE_POLL_MS = 10_000;
|
||||||
|
const BROWSER_IDLE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
interface ElectronIdleApi {
|
||||||
|
onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void;
|
||||||
|
getIdleState: () => Promise<'active' | 'idle'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdleAwareWindow = Window & {
|
||||||
|
electronAPI?: ElectronIdleApi;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrates user status based on idle detection (Electron powerMonitor
|
||||||
|
* or browser-fallback) and manual overrides (e.g. Do Not Disturb).
|
||||||
|
*
|
||||||
|
* Manual status always takes priority over automatic idle detection.
|
||||||
|
* When manual status is cleared, the service falls back to automatic.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class UserStatusService implements OnDestroy {
|
||||||
|
private store = inject(Store);
|
||||||
|
private zone = inject(NgZone);
|
||||||
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
|
private audio = inject(NotificationAudioService);
|
||||||
|
private readonly manualStatus = this.store.selectSignal(selectManualStatus);
|
||||||
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
|
private electronCleanup: (() => void) | null = null;
|
||||||
|
private browserPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private lastActivityTimestamp = Date.now();
|
||||||
|
private browserActivityListeners: (() => void)[] = [];
|
||||||
|
private currentAutoStatus: UserStatus = 'online';
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.started)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.started = true;
|
||||||
|
|
||||||
|
if (this.getElectronIdleApi()?.onIdleStateChanged) {
|
||||||
|
this.startElectronIdleDetection();
|
||||||
|
} else {
|
||||||
|
this.startBrowserIdleDetection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set a manual status override (e.g. DND = 'busy'). Pass `null` to clear. */
|
||||||
|
setManualStatus(status: UserStatus | null): void {
|
||||||
|
this.store.dispatch(UsersActions.setManualStatus({ status }));
|
||||||
|
this.audio.dndMuted.set(status === 'busy');
|
||||||
|
this.broadcastStatus(this.resolveEffectiveStatus(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
this.electronCleanup?.();
|
||||||
|
this.electronCleanup = null;
|
||||||
|
|
||||||
|
if (this.browserPollTimer) {
|
||||||
|
clearInterval(this.browserPollTimer);
|
||||||
|
this.browserPollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const remove of this.browserActivityListeners) {
|
||||||
|
remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.browserActivityListeners = [];
|
||||||
|
this.started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startElectronIdleDetection(): void {
|
||||||
|
const api = this.getElectronIdleApi();
|
||||||
|
|
||||||
|
if (!api)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.electronCleanup = api.onIdleStateChanged((idleState: 'active' | 'idle') => {
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
|
||||||
|
this.applyAutoStatusIfAllowed();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check initial state
|
||||||
|
api.getIdleState().then((idleState: 'active' | 'idle') => {
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
|
||||||
|
this.applyAutoStatusIfAllowed();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startBrowserIdleDetection(): void {
|
||||||
|
this.lastActivityTimestamp = Date.now();
|
||||||
|
|
||||||
|
const onActivity = () => {
|
||||||
|
this.lastActivityTimestamp = Date.now();
|
||||||
|
const wasAway = this.currentAutoStatus === 'away';
|
||||||
|
|
||||||
|
if (wasAway) {
|
||||||
|
this.currentAutoStatus = 'online';
|
||||||
|
this.zone.run(() => this.applyAutoStatusIfAllowed());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const events = [
|
||||||
|
'mousemove',
|
||||||
|
'keydown',
|
||||||
|
'mousedown',
|
||||||
|
'touchstart',
|
||||||
|
'scroll'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const evt of events) {
|
||||||
|
document.addEventListener(evt, onActivity, { passive: true });
|
||||||
|
this.browserActivityListeners.push(() =>
|
||||||
|
document.removeEventListener(evt, onActivity)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.zone.runOutsideAngular(() => {
|
||||||
|
this.browserPollTimer = setInterval(() => {
|
||||||
|
const idle = Date.now() - this.lastActivityTimestamp >= BROWSER_IDLE_THRESHOLD_MS;
|
||||||
|
|
||||||
|
if (idle && this.currentAutoStatus !== 'away') {
|
||||||
|
this.currentAutoStatus = 'away';
|
||||||
|
this.zone.run(() => this.applyAutoStatusIfAllowed());
|
||||||
|
}
|
||||||
|
}, BROWSER_IDLE_POLL_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyAutoStatusIfAllowed(): void {
|
||||||
|
const manualStatus = this.manualStatus();
|
||||||
|
|
||||||
|
// Manual status overrides automatic
|
||||||
|
if (manualStatus)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const currentUser = this.currentUser();
|
||||||
|
|
||||||
|
if (currentUser?.status !== this.currentAutoStatus) {
|
||||||
|
this.store.dispatch(UsersActions.setManualStatus({ status: null }));
|
||||||
|
this.store.dispatch(UsersActions.updateCurrentUser({ updates: { status: this.currentAutoStatus } }));
|
||||||
|
this.broadcastStatus(this.currentAutoStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveEffectiveStatus(manualStatus: UserStatus | null): UserStatus {
|
||||||
|
return manualStatus ?? this.currentAutoStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastStatus(status: UserStatus): void {
|
||||||
|
this.webrtc.sendRawMessage({
|
||||||
|
type: 'status_update',
|
||||||
|
status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getElectronIdleApi(): ElectronIdleApi | undefined {
|
||||||
|
return (window as IdleAwareWindow).electronAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
toju-app/src/app/core/storage/current-user-storage.ts
Normal file
59
toju-app/src/app/core/storage/current-user-storage.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { STORAGE_KEY_CURRENT_USER_ID } from '../constants';
|
||||||
|
|
||||||
|
const METOYOU_STORAGE_PREFIX = 'metoyou_';
|
||||||
|
|
||||||
|
function normaliseStorageUserId(userId?: string | null): string | null {
|
||||||
|
const trimmedUserId = userId?.trim();
|
||||||
|
|
||||||
|
return trimmedUserId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredCurrentUserId(): string | null {
|
||||||
|
try {
|
||||||
|
const raw = normaliseStorageUserId(localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID));
|
||||||
|
|
||||||
|
return raw || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserScopedStorageKey(baseKey: string, userId?: string | null): string {
|
||||||
|
const scopedUserId = userId === undefined
|
||||||
|
? getStoredCurrentUserId()
|
||||||
|
: normaliseStorageUserId(userId);
|
||||||
|
|
||||||
|
return scopedUserId
|
||||||
|
? `${baseKey}__${encodeURIComponent(scopedUserId)}`
|
||||||
|
: baseKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredCurrentUserId(userId: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, userId);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredCurrentUserId(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredLocalAppData(): void {
|
||||||
|
try {
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < localStorage.length; index += 1) {
|
||||||
|
const key = localStorage.key(index);
|
||||||
|
|
||||||
|
if (key?.startsWith(METOYOU_STORAGE_PREFIX)) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keysToRemove) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
@@ -12,7 +12,10 @@ infrastructure adapters and UI.
|
|||||||
| **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` |
|
| **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` |
|
||||||
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
|
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
|
||||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||||
|
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
|
||||||
|
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
|
||||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||||
|
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
||||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||||
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
|
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
|
||||||
@@ -27,7 +30,9 @@ The larger domains also keep longer design notes in their own folders:
|
|||||||
- [access-control/README.md](access-control/README.md)
|
- [access-control/README.md](access-control/README.md)
|
||||||
- [authentication/README.md](authentication/README.md)
|
- [authentication/README.md](authentication/README.md)
|
||||||
- [chat/README.md](chat/README.md)
|
- [chat/README.md](chat/README.md)
|
||||||
|
- [direct-message/README.md](direct-message/README.md)
|
||||||
- [notifications/README.md](notifications/README.md)
|
- [notifications/README.md](notifications/README.md)
|
||||||
|
- [profile-avatar/README.md](profile-avatar/README.md)
|
||||||
- [screen-share/README.md](screen-share/README.md)
|
- [screen-share/README.md](screen-share/README.md)
|
||||||
- [server-directory/README.md](server-directory/README.md)
|
- [server-directory/README.md](server-directory/README.md)
|
||||||
- [voice-connection/README.md](voice-connection/README.md)
|
- [voice-connection/README.md](voice-connection/README.md)
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { RealtimeSessionFacade } from '../../../../core/realtime';
|
|||||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||||
import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants';
|
import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants';
|
||||||
import { FileChunkEvent } from '../../domain/models/attachment-transfer.model';
|
import { FileChunkEvent } from '../../domain/models/attachment-transfer.model';
|
||||||
|
import {
|
||||||
|
arrayBufferToBase64,
|
||||||
|
decodeBase64,
|
||||||
|
iterateBlobChunks
|
||||||
|
} from '../../../../shared-kernel';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AttachmentTransferTransportService {
|
export class AttachmentTransferTransportService {
|
||||||
@@ -10,14 +15,7 @@ export class AttachmentTransferTransportService {
|
|||||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||||
|
|
||||||
decodeBase64(base64: string): Uint8Array {
|
decodeBase64(base64: string): Uint8Array {
|
||||||
const binary = atob(base64);
|
return decodeBase64(base64);
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
|
|
||||||
for (let index = 0; index < binary.length; index++) {
|
|
||||||
bytes[index] = binary.charCodeAt(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async streamFileToPeer(
|
async streamFileToPeer(
|
||||||
@@ -27,31 +25,20 @@ export class AttachmentTransferTransportService {
|
|||||||
file: File,
|
file: File,
|
||||||
isCancelled: () => boolean
|
isCancelled: () => boolean
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES);
|
for await (const chunk of iterateBlobChunks(file, FILE_CHUNK_SIZE_BYTES)) {
|
||||||
|
|
||||||
let offset = 0;
|
|
||||||
let chunkIndex = 0;
|
|
||||||
|
|
||||||
while (offset < file.size) {
|
|
||||||
if (isCancelled())
|
if (isCancelled())
|
||||||
break;
|
break;
|
||||||
|
|
||||||
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
|
|
||||||
const arrayBuffer = await slice.arrayBuffer();
|
|
||||||
const base64 = this.arrayBufferToBase64(arrayBuffer);
|
|
||||||
const fileChunkEvent: FileChunkEvent = {
|
const fileChunkEvent: FileChunkEvent = {
|
||||||
type: 'file-chunk',
|
type: 'file-chunk',
|
||||||
messageId,
|
messageId,
|
||||||
fileId,
|
fileId,
|
||||||
index: chunkIndex,
|
index: chunk.index,
|
||||||
total: totalChunks,
|
total: chunk.total,
|
||||||
data: base64
|
data: chunk.base64
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||||
|
|
||||||
offset += FILE_CHUNK_SIZE_BYTES;
|
|
||||||
chunkIndex++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +54,7 @@ export class AttachmentTransferTransportService {
|
|||||||
if (!base64Full)
|
if (!base64Full)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const fileBytes = this.decodeBase64(base64Full);
|
const fileBytes = decodeBase64(base64Full);
|
||||||
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
|
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
|
||||||
|
|
||||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||||
@@ -81,7 +68,7 @@ export class AttachmentTransferTransportService {
|
|||||||
slice.byteOffset,
|
slice.byteOffset,
|
||||||
slice.byteOffset + slice.byteLength
|
slice.byteOffset + slice.byteLength
|
||||||
);
|
);
|
||||||
const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
|
const base64Chunk = arrayBufferToBase64(sliceBuffer);
|
||||||
const fileChunkEvent: FileChunkEvent = {
|
const fileChunkEvent: FileChunkEvent = {
|
||||||
type: 'file-chunk',
|
type: 'file-chunk',
|
||||||
messageId,
|
messageId,
|
||||||
@@ -94,16 +81,4 @@ export class AttachmentTransferTransportService {
|
|||||||
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
|
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
||||||
let binary = '';
|
|
||||||
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
|
|
||||||
for (let index = 0; index < bytes.byteLength; index++) {
|
|
||||||
binary += String.fromCharCode(bytes[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
|
export { P2P_BASE64_CHUNK_SIZE_BYTES as FILE_CHUNK_SIZE_BYTES } from '../../../../shared-kernel/p2p-transfer.constants';
|
||||||
export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EWMA smoothing weight for the previous speed estimate.
|
* EWMA smoothing weight for the previous speed estimate.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ authentication/
|
|||||||
|
|
||||||
## Service overview
|
## Service overview
|
||||||
|
|
||||||
`AuthenticationService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component stores `currentUserId` in localStorage and dispatches `UsersActions.setCurrentUser` into the NgRx store.
|
`AuthenticationService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component dispatches `UsersActions.authenticateUser`, and the users effects prepare the local persistence boundary before exposing the new user in the NgRx store.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
@@ -58,6 +58,7 @@ sequenceDiagram
|
|||||||
participant SD as ServerDirectoryFacade
|
participant SD as ServerDirectoryFacade
|
||||||
participant API as Server API
|
participant API as Server API
|
||||||
participant Store as NgRx Store
|
participant Store as NgRx Store
|
||||||
|
participant Effects as UsersEffects
|
||||||
|
|
||||||
User->>Login: Submit credentials
|
User->>Login: Submit credentials
|
||||||
Login->>Auth: login(username, password)
|
Login->>Auth: login(username, password)
|
||||||
@@ -66,13 +67,15 @@ sequenceDiagram
|
|||||||
Auth->>API: POST /api/auth/login
|
Auth->>API: POST /api/auth/login
|
||||||
API-->>Auth: { userId, displayName }
|
API-->>Auth: { userId, displayName }
|
||||||
Auth-->>Login: success
|
Auth-->>Login: success
|
||||||
Login->>Store: UsersActions.setCurrentUser
|
Login->>Store: UsersActions.authenticateUser
|
||||||
Login->>Login: localStorage.setItem(currentUserId)
|
Store->>Effects: prepare persisted user scope
|
||||||
|
Effects->>Store: reset stale room/user/message state
|
||||||
|
Effects->>Store: UsersActions.setCurrentUser
|
||||||
```
|
```
|
||||||
|
|
||||||
## Registration flow
|
## Registration flow
|
||||||
|
|
||||||
Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same store dispatch happens.
|
Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same authenticated-user transition runs, switching the browser persistence layer to that user's local scope before the app reloads rooms and user state.
|
||||||
|
|
||||||
## User bar
|
## User bar
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { AuthenticationService } from '../../application/services/authentication
|
|||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { User } from '../../../../shared-kernel';
|
import { User } from '../../../../shared-kernel';
|
||||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
@@ -70,9 +69,7 @@ export class LoginComponent {
|
|||||||
joinedAt: Date.now()
|
joinedAt: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
this.store.dispatch(UsersActions.authenticateUser({ user }));
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
|
||||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||||
|
|
||||||
if (returnUrl?.startsWith('/')) {
|
if (returnUrl?.startsWith('/')) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { AuthenticationService } from '../../application/services/authentication
|
|||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { User } from '../../../../shared-kernel';
|
import { User } from '../../../../shared-kernel';
|
||||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-register',
|
selector: 'app-register',
|
||||||
@@ -72,9 +71,7 @@ export class RegisterComponent {
|
|||||||
joinedAt: Date.now()
|
joinedAt: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
this.store.dispatch(UsersActions.authenticateUser({ user }));
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
|
||||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||||
|
|
||||||
if (returnUrl?.startsWith('/')) {
|
if (returnUrl?.startsWith('/')) {
|
||||||
|
|||||||
@@ -1,35 +1,45 @@
|
|||||||
<div class="h-10 border-b border-border bg-card flex items-center justify-end px-3 gap-2">
|
<div class="w-full border-t border-border bg-card/50 px-1 py-2">
|
||||||
<div class="flex-1"></div>
|
|
||||||
@if (user()) {
|
@if (user()) {
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex flex-col items-center gap-1 text-xs">
|
||||||
<ng-icon
|
<button
|
||||||
name="lucideUser"
|
#avatarBtn
|
||||||
class="w-4 h-4 text-muted-foreground"
|
type="button"
|
||||||
/>
|
class="rounded-full transition-opacity hover:opacity-90"
|
||||||
<span class="text-foreground">{{ user()?.displayName }}</span>
|
(click)="toggleProfileCard(avatarBtn)"
|
||||||
|
>
|
||||||
|
<app-user-avatar
|
||||||
|
[name]="user()!.displayName"
|
||||||
|
[avatarUrl]="user()!.avatarUrl"
|
||||||
|
size="sm"
|
||||||
|
[status]="user()!.status"
|
||||||
|
[showStatusBadge]="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<button
|
<div class="flex flex-col items-center gap-1">
|
||||||
type="button"
|
<button
|
||||||
(click)="goto('login')"
|
type="button"
|
||||||
class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1"
|
(click)="goto('login')"
|
||||||
>
|
class="w-full px-1 py-1 text-[10px] rounded bg-secondary hover:bg-secondary/80 flex items-center justify-center gap-1"
|
||||||
<ng-icon
|
>
|
||||||
name="lucideLogIn"
|
<ng-icon
|
||||||
class="w-4 h-4"
|
name="lucideLogIn"
|
||||||
/>
|
class="w-3 h-3"
|
||||||
Login
|
/>
|
||||||
</button>
|
Login
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
(click)="goto('register')"
|
type="button"
|
||||||
class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1"
|
(click)="goto('register')"
|
||||||
>
|
class="w-full px-1 py-1 text-[10px] rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center justify-center gap-1"
|
||||||
<ng-icon
|
>
|
||||||
name="lucideUserPlus"
|
<ng-icon
|
||||||
class="w-4 h-4"
|
name="lucideUserPlus"
|
||||||
/>
|
class="w-3 h-3"
|
||||||
Register
|
/>
|
||||||
</button>
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,19 +3,21 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import { lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
|
||||||
lucideUser,
|
|
||||||
lucideLogIn,
|
|
||||||
lucideUserPlus
|
|
||||||
} from '@ng-icons/lucide';
|
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
|
import { ProfileCardService } from '../../../../shared/components/profile-card/profile-card.service';
|
||||||
|
import { UserAvatarComponent } from '../../../../shared';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-bar',
|
selector: 'app-user-bar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
NgIcon,
|
||||||
|
UserAvatarComponent
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({ lucideUser,
|
provideIcons({
|
||||||
lucideLogIn,
|
lucideLogIn,
|
||||||
lucideUserPlus })
|
lucideUserPlus })
|
||||||
],
|
],
|
||||||
@@ -29,6 +31,16 @@ export class UserBarComponent {
|
|||||||
user = this.store.selectSignal(selectCurrentUser);
|
user = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private profileCard = inject(ProfileCardService);
|
||||||
|
|
||||||
|
toggleProfileCard(origin: HTMLElement): void {
|
||||||
|
const user = this.user();
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.profileCard.open(origin, user, { placement: 'above', editable: true });
|
||||||
|
}
|
||||||
|
|
||||||
/** Navigate to the specified authentication page. */
|
/** Navigate to the specified authentication page. */
|
||||||
goto(path: 'login' | 'register') {
|
goto(path: 'login' | 'register') {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -13,7 +10,11 @@ import {
|
|||||||
throwError
|
throwError
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { catchError, map } from 'rxjs/operators';
|
import { catchError, map } from 'rxjs/operators';
|
||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
import {
|
||||||
|
ServerDirectoryFacade,
|
||||||
|
type RoomSignalSourceInput,
|
||||||
|
type ServerSourceSelector
|
||||||
|
} from '../../../server-directory';
|
||||||
|
|
||||||
export interface KlipyGif {
|
export interface KlipyGif {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,51 +38,47 @@ export interface KlipyGifSearchResponse {
|
|||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 24;
|
const DEFAULT_PAGE_SIZE = 24;
|
||||||
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
|
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
|
||||||
|
const DEFAULT_AVAILABILITY_KEY = 'default';
|
||||||
|
|
||||||
|
interface KlipyAvailabilityState {
|
||||||
|
enabled: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class KlipyService {
|
export class KlipyService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
private readonly availabilityState = signal({
|
private readonly availabilityByKey = signal<Record<string, KlipyAvailabilityState>>({});
|
||||||
enabled: false,
|
|
||||||
loading: true
|
|
||||||
});
|
|
||||||
private lastAvailabilityKey = '';
|
|
||||||
|
|
||||||
readonly isEnabled = computed(() => this.availabilityState().enabled);
|
isEnabled(source?: RoomSignalSourceInput | null): boolean {
|
||||||
readonly isLoading = computed(() => this.availabilityState().loading);
|
return this.getAvailabilityState(source).enabled;
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
const activeServer = this.serverDirectory.activeServer();
|
|
||||||
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
|
|
||||||
const nextKey = `${activeServer?.id ?? 'default'}:${apiBaseUrl}`;
|
|
||||||
|
|
||||||
if (nextKey === this.lastAvailabilityKey)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.lastAvailabilityKey = nextKey;
|
|
||||||
void this.refreshAvailability();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshAvailability(): Promise<void> {
|
isLoading(source?: RoomSignalSourceInput | null): boolean {
|
||||||
this.availabilityState.set({ enabled: false,
|
return this.getAvailabilityState(source).loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAvailability(source?: RoomSignalSourceInput | null): Promise<void> {
|
||||||
|
const selector = this.getSourceSelector(source);
|
||||||
|
const key = this.getAvailabilityKey(selector);
|
||||||
|
|
||||||
|
this.setAvailabilityState(key, { enabled: false,
|
||||||
loading: true });
|
loading: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.http.get<KlipyAvailabilityResponse>(
|
this.http.get<KlipyAvailabilityResponse>(
|
||||||
`${this.serverDirectory.getApiBaseUrl()}/klipy/config`
|
`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/config`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.availabilityState.set({
|
this.setAvailabilityState(key, {
|
||||||
enabled: response.enabled === true,
|
enabled: response.enabled === true,
|
||||||
loading: false
|
loading: false
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
this.availabilityState.set({ enabled: false,
|
this.setAvailabilityState(key, { enabled: false,
|
||||||
loading: false });
|
loading: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,8 +86,11 @@ export class KlipyService {
|
|||||||
searchGifs(
|
searchGifs(
|
||||||
query: string,
|
query: string,
|
||||||
page = 1,
|
page = 1,
|
||||||
perPage = DEFAULT_PAGE_SIZE
|
perPage = DEFAULT_PAGE_SIZE,
|
||||||
|
source?: RoomSignalSourceInput | null
|
||||||
): Observable<KlipyGifSearchResponse> {
|
): Observable<KlipyGifSearchResponse> {
|
||||||
|
const selector = this.getSourceSelector(source);
|
||||||
|
|
||||||
let params = new HttpParams()
|
let params = new HttpParams()
|
||||||
.set('page', String(Math.max(1, Math.floor(page))))
|
.set('page', String(Math.max(1, Math.floor(page))))
|
||||||
.set('per_page', String(Math.max(1, Math.floor(perPage))))
|
.set('per_page', String(Math.max(1, Math.floor(perPage))))
|
||||||
@@ -109,7 +109,7 @@ export class KlipyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.http
|
return this.http
|
||||||
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params })
|
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/gifs`, { params })
|
||||||
.pipe(
|
.pipe(
|
||||||
map((response) => ({
|
map((response) => ({
|
||||||
enabled: response.enabled !== false,
|
enabled: response.enabled !== false,
|
||||||
@@ -138,7 +138,7 @@ export class KlipyService {
|
|||||||
return this.normalizeMediaUrl(url);
|
return this.normalizeMediaUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildImageProxyUrl(url: string): string {
|
buildImageProxyUrl(url: string, source?: RoomSignalSourceInput | null): string {
|
||||||
const trimmed = this.normalizeMediaUrl(url);
|
const trimmed = this.normalizeMediaUrl(url);
|
||||||
|
|
||||||
if (!trimmed)
|
if (!trimmed)
|
||||||
@@ -147,7 +147,36 @@ export class KlipyService {
|
|||||||
if (!/^https?:\/\//i.test(trimmed))
|
if (!/^https?:\/\//i.test(trimmed))
|
||||||
return trimmed;
|
return trimmed;
|
||||||
|
|
||||||
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`;
|
return `${this.serverDirectory.getApiBaseUrl(this.getSourceSelector(source))}/image-proxy?url=${encodeURIComponent(trimmed)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAvailabilityState(source?: RoomSignalSourceInput | null): KlipyAvailabilityState {
|
||||||
|
return this.availabilityByKey()[this.getAvailabilityKey(this.getSourceSelector(source))]
|
||||||
|
?? { enabled: false,
|
||||||
|
loading: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private setAvailabilityState(key: string, state: KlipyAvailabilityState): void {
|
||||||
|
this.availabilityByKey.update((availabilityByKey) => ({
|
||||||
|
...availabilityByKey,
|
||||||
|
[key]: state
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSourceSelector(source?: RoomSignalSourceInput | null): ServerSourceSelector | undefined {
|
||||||
|
return this.serverDirectory.buildRoomSignalSelector(source ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAvailabilityKey(selector?: ServerSourceSelector): string {
|
||||||
|
if (selector?.sourceId) {
|
||||||
|
return `id:${selector.sourceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selector?.sourceUrl) {
|
||||||
|
return `url:${selector.sourceUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_AVAILABILITY_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPreferredLocale(): string | null {
|
private getPreferredLocale(): string | null {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class LinkMetadataService {
|
|||||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
extractUrls(content: string): string[] {
|
extractUrls(content: string): string[] {
|
||||||
return [...content.matchAll(URL_PATTERN)].map((m) => m[0]);
|
return [...content.matchAll(URL_PATTERN)].map((match) => match[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchMetadata(url: string): Promise<LinkMetadata> {
|
async fetchMetadata(url: string): Promise<LinkMetadata> {
|
||||||
|
|||||||
149
toju-app/src/app/domains/chat/domain/rules/link-embed.rules.ts
Normal file
149
toju-app/src/app/domains/chat/domain/rules/link-embed.rules.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
export type SpotifyResourceType = 'album' | 'artist' | 'episode' | 'playlist' | 'show' | 'track';
|
||||||
|
export type SoundcloudResourceType = 'playlist' | 'track';
|
||||||
|
|
||||||
|
export interface SpotifyResource {
|
||||||
|
type: SpotifyResourceType;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoundcloudResource {
|
||||||
|
canonicalUrl: string;
|
||||||
|
type: SoundcloudResourceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPOTIFY_RESOURCE_TYPES = new Set<SpotifyResourceType>([
|
||||||
|
'album',
|
||||||
|
'artist',
|
||||||
|
'episode',
|
||||||
|
'playlist',
|
||||||
|
'show',
|
||||||
|
'track'
|
||||||
|
]);
|
||||||
|
const SPOTIFY_URI_PATTERN = /^spotify:(album|artist|episode|playlist|show|track):([a-zA-Z0-9]+)$/i;
|
||||||
|
const SOUNDCLOUD_HOST_PATTERN = /^(?:www\.|m\.)?soundcloud\.com$/i;
|
||||||
|
const YOUTUBE_HOST_PATTERN = /^(?:www\.|m\.|music\.)?youtube\.com$/i;
|
||||||
|
const YOUTU_BE_HOST_PATTERN = /^(?:www\.)?youtu\.be$/i;
|
||||||
|
const YOUTUBE_VIDEO_ID_PATTERN = /^[\w-]{11}$/;
|
||||||
|
|
||||||
|
function parseUrl(url: string): URL | null {
|
||||||
|
try {
|
||||||
|
return new URL(url);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractYoutubeVideoId(url: string): string | null {
|
||||||
|
const parsedUrl = parseUrl(url);
|
||||||
|
|
||||||
|
if (!parsedUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (YOUTU_BE_HOST_PATTERN.test(parsedUrl.hostname)) {
|
||||||
|
const shortId = parsedUrl.pathname.split('/').filter(Boolean)[0] ?? '';
|
||||||
|
|
||||||
|
return YOUTUBE_VIDEO_ID_PATTERN.test(shortId) ? shortId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!YOUTUBE_HOST_PATTERN.test(parsedUrl.hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
if (parsedUrl.pathname === '/watch') {
|
||||||
|
const queryId = parsedUrl.searchParams.get('v') ?? '';
|
||||||
|
|
||||||
|
return YOUTUBE_VIDEO_ID_PATTERN.test(queryId) ? queryId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathSegments.length >= 2 && (pathSegments[0] === 'embed' || pathSegments[0] === 'shorts')) {
|
||||||
|
const pathId = pathSegments[1];
|
||||||
|
|
||||||
|
return YOUTUBE_VIDEO_ID_PATTERN.test(pathId) ? pathId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isYoutubeUrl(url?: string): boolean {
|
||||||
|
return !!url && extractYoutubeVideoId(url) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSpotifyResource(url: string): SpotifyResource | null {
|
||||||
|
const spotifyUriMatch = url.match(SPOTIFY_URI_PATTERN);
|
||||||
|
|
||||||
|
if (spotifyUriMatch?.[1] && spotifyUriMatch[2]) {
|
||||||
|
return {
|
||||||
|
type: spotifyUriMatch[1].toLowerCase() as SpotifyResourceType,
|
||||||
|
id: spotifyUriMatch[2]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedUrl = parseUrl(url);
|
||||||
|
|
||||||
|
if (!parsedUrl || !/^(?:open|play)\.spotify\.com$/i.test(parsedUrl.hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
if (segments.length >= 2 && SPOTIFY_RESOURCE_TYPES.has(segments[0] as SpotifyResourceType)) {
|
||||||
|
return {
|
||||||
|
type: segments[0] as SpotifyResourceType,
|
||||||
|
id: segments[1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length >= 4 && segments[0] === 'user' && segments[2] === 'playlist') {
|
||||||
|
return {
|
||||||
|
type: 'playlist',
|
||||||
|
id: segments[3]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSpotifyUrl(url?: string): boolean {
|
||||||
|
return !!url && extractSpotifyResource(url) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSoundcloudResource(url: string): SoundcloudResource | null {
|
||||||
|
const parsedUrl = parseUrl(url);
|
||||||
|
|
||||||
|
if (!parsedUrl || !SOUNDCLOUD_HOST_PATTERN.test(parsedUrl.hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
if (segments.length === 2) {
|
||||||
|
const canonicalUrl = new URL(`https://soundcloud.com/${segments[0]}/${segments[1]}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canonicalUrl: canonicalUrl.toString(),
|
||||||
|
type: 'track'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length === 3 && segments[1] === 'sets') {
|
||||||
|
const canonicalUrl = new URL(`https://soundcloud.com/${segments[0]}/sets/${segments[2]}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canonicalUrl: canonicalUrl.toString(),
|
||||||
|
type: 'playlist'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSoundcloudUrl(url?: string): boolean {
|
||||||
|
return !!url && extractSoundcloudResource(url) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDedicatedChatEmbed(url?: string): boolean {
|
||||||
|
return isYoutubeUrl(url) || isSpotifyUrl(url) || isSoundcloudUrl(url);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user