46 Commits

Author SHA1 Message Date
47beed01ca docs: add cross-context feature docs for auth, presence, access-control, messaging, attachments
Fills the five highest-value gaps under agents-docs/features/ so the index covers
the system's main cross-context contracts. Each doc follows the feature-template
structure and the AGENTS_FEATURES.md contract, with honest TODOs where coverage
or behavior couldn't be confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:33:41 +02:00
d5ef0b84d8 add skills 2026-05-25 15:38:26 +02:00
b19c39208c docs: populate initial cross-context feature docs
Add area-level documentation for the five most significant cross-context
feature areas under agents-docs/features/:

- websocket-envelopes: full envelope catalogue, lifecycle, dispatcher
- ipc-bridge: window.electronAPI surface, IPC channels, CQRS dispatch
- plugin-system: manifest contract, runtime, capabilities, plugin-support API
- server-directory: REST endpoints, CQRS, entities, business rules
- voice-signaling: mesh signaling, RNNoise pipeline, domain split

Update agents-docs/FEATURES.md index alphabetically and remove the
"no cross-context feature docs" placeholder.

Each doc records honest TODOs for verified gaps (stale signaling-contracts.ts,
window.api vs window.electronAPI mismatch, IPC error envelope drift from
CONTEXT.md, missing OpenAPI coverage for server-directory routes, no
envelope round-trip test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:36:36 +02:00
c48b6e9c94 docs: scaffold agent instruction tree
Add AGENTS.md, CLAUDE.md, and the agents-docs/ tree (workflow, lessons,
engineering standards, context map, ADR seed, feature template) plus a
domain-bearing CONTEXT.md for each of the six subdomains: toju-app,
electron, server, e2e, website, docs-site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:02:02 +02:00
Myx
232a9ea8ea test: Ensure tests work after latest changes
All checks were successful
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 7m20s
Queue Release Build / build-windows (push) Successful in 25m4s
Queue Release Build / build-linux (push) Successful in 33m59s
Queue Release Build / finalize (push) Successful in 41s
2026-05-19 00:52:28 +02:00
Myx
54e8b9a5e4 feat: Update how messages load and sync, allow plugins to import messages
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 7m36s
Queue Release Build / build-windows (push) Successful in 28m3s
Queue Release Build / build-linux (push) Successful in 44m14s
Queue Release Build / finalize (push) Successful in 39s
2026-05-18 23:21:09 +02:00
Myx
94428ed170 fix: Mobile style fixes and other small ui fixes 2026-05-18 23:20:32 +02:00
Myx
afb64520ed perf: server navigation 2026-05-18 19:38:08 +02:00
Myx
0152ed9dd2 fix: memory leak hunting and reconnecting on data error 2026-05-18 19:37:30 +02:00
Myx
dea114aed0 feat: Response mobile layout support v1
All checks were successful
Queue Release Build / prepare (push) Successful in 1m6s
Deploy Web Apps / deploy (push) Successful in 7m35s
Queue Release Build / build-windows (push) Successful in 29m57s
Queue Release Build / build-linux (push) Successful in 46m28s
Queue Release Build / finalize (push) Successful in 49s
2026-05-18 03:03:55 +02:00
Myx
ecb1a4b3a0 refactor: Remove hardcoded values
All checks were successful
Queue Release Build / prepare (push) Successful in 2m28s
Deploy Web Apps / deploy (push) Successful in 7m58s
Queue Release Build / build-linux (push) Successful in 46m59s
Queue Release Build / build-windows (push) Successful in 26m2s
Queue Release Build / finalize (push) Successful in 23s
2026-05-17 18:18:14 +02:00
Myx
a173299ad3 fix: Game detection improvements
Some checks failed
Queue Release Build / prepare (push) Successful in 27s
Deploy Web Apps / deploy (push) Successful in 10m8s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled
2026-05-17 17:47:40 +02:00
Myx
8631290c01 fix: Improve plugin ui entry points, Fix chat scroll, fix notifications, fix user rights 2026-05-17 16:09:16 +02:00
Myx
8e3ccf4157 feat: Add incoming call modal 2026-05-17 16:08:24 +02:00
Myx
9d0a4478b2 Repair connectivity correctly v1 2026-05-17 15:15:14 +02:00
Myx
e769a6ee4a Fix private calls 2026-05-17 15:14:52 +02:00
Myx
0f6cb3ee77 fix: browser bug with plugins, and improve joining 2026-05-04 23:35:40 +02:00
Myx
a49e18b9f0 fix: recurriing network issue
All checks were successful
Queue Release Build / prepare (push) Successful in 18s
Deploy Web Apps / deploy (push) Successful in 6m32s
Queue Release Build / build-windows (push) Successful in 26m8s
Queue Release Build / build-linux (push) Successful in 40m18s
Queue Release Build / finalize (push) Successful in 42s
2026-04-30 04:04:34 +02:00
b1fe286be8 Merge pull request 'Plugins' (#14) from Plugins into main
All checks were successful
Queue Release Build / prepare (push) Successful in 20s
Deploy Web Apps / deploy (push) Successful in 8m30s
Queue Release Build / build-windows (push) Successful in 25m24s
Queue Release Build / build-linux (push) Successful in 41m32s
Queue Release Build / finalize (push) Successful in 30s
Reviewed-on: #14
2026-04-29 23:18:22 +00:00
Myx
0a714428f6 docs: improve doucmentation
improve doucmentation and fix small store changes
2026-04-30 01:16:48 +02:00
Myx
3f92e74350 feat: expose more apis 2026-04-29 23:39:09 +02:00
Myx
fa2cca6fa4 fix: improve plugins functionality with server management 2026-04-29 20:33:54 +02:00
Myx
b8f6d58d99 test: repair broken tests 2026-04-29 19:05:38 +02:00
Myx
e1ac1d1bc0 feat: server image 2026-04-29 18:54:08 +02:00
Myx
3d81c34159 feat: Add browser documentation 2026-04-29 17:15:01 +02:00
Myx
d261bac0ed feat: plugins v1.7 2026-04-29 15:24:56 +02:00
Myx
eabbc08896 feat: plugins v1.5 2026-04-29 01:14:30 +02:00
Myx
6920f93b41 feat: plugins v1 2026-04-29 01:14:14 +02:00
Myx
ec3802ade6 test: fix broken dm test
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 6m5s
Queue Release Build / build-windows (push) Successful in 17m1s
Queue Release Build / build-linux (push) Successful in 29m15s
Queue Release Build / finalize (push) Successful in 38s
2026-04-27 22:48:45 +02:00
Myx
66c6f34cd3 feat: Add game activity status (Experimental)
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
2026-04-27 11:02:34 +02:00
Myx
3858beb28e feat: Data management 2026-04-27 03:29:41 +02:00
Myx
1b91eacb5b feat: Theme studio v2 2026-04-27 03:02:13 +02:00
Myx
11c2588e45 feat: Add pm 2026-04-27 01:02:39 +02:00
Myx
bc2fa7de22 fix: multiple bug fixes
isolated users, db backup, weird disconnect issues for long voice sessions,
2026-04-26 22:54:13 +02:00
Myx
44588e8789 feat: Add TURN server support
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 5m35s
Queue Release Build / build-linux (push) Successful in 24m45s
Queue Release Build / build-windows (push) Successful in 13m52s
Queue Release Build / finalize (push) Successful in 23s
2026-04-18 21:27:04 +02:00
Myx
167c45ba8d test: Add 8 people voice tests 2026-04-18 14:24:11 +02:00
Myx
bd21568726 feat: Add user metadata changing display name and description with sync
All checks were successful
Queue Release Build / prepare (push) Successful in 28s
Deploy Web Apps / deploy (push) Successful in 5m2s
Queue Release Build / build-windows (push) Successful in 16m44s
Queue Release Build / build-linux (push) Successful in 27m12s
Queue Release Build / finalize (push) Successful in 22s
2026-04-17 22:55:50 +02:00
Myx
3ba8a2c9eb fix: Fix corrupt database, Add soundcloud and spotify embeds 2026-04-17 19:44:26 +02:00
Myx
28797a0141 perf: use lookup for chats
Some checks failed
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 15m8s
Queue Release Build / build-linux (push) Successful in 26m49s
Queue Release Build / build-windows (push) Failing after 12m6s
Queue Release Build / finalize (push) Has been skipped
2026-04-17 03:53:53 +02:00
Myx
17738ec484 feat: Add profile images 2026-04-17 03:06:44 +02:00
Myx
35b616fb77 refactor: Clean lint errors and organise files 2026-04-17 01:06:01 +02:00
Myx
2927a86fbb feat: Add user statuses and cards 2026-04-16 22:52:45 +02:00
Myx
b4ac0cdc92 fix: Windows audio mute fix 2026-04-16 19:07:44 +02:00
Myx
f3b56fb1cc fix: Db corruption fix
All checks were successful
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 10m0s
Queue Release Build / build-linux (push) Successful in 25m59s
Queue Release Build / build-windows (push) Successful in 21m44s
Queue Release Build / finalize (push) Successful in 18s
2026-04-13 02:23:09 +02:00
Myx
315820d487 ci: attempt to fix
All checks were successful
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 11m21s
Queue Release Build / build-linux (push) Successful in 25m31s
Queue Release Build / build-windows (push) Successful in 21m42s
Queue Release Build / finalize (push) Successful in 22s
2026-04-12 22:05:39 +02:00
Myx
878fd1c766 ci: Attempt to speed up build
Some checks failed
Queue Release Build / prepare (push) Successful in 50s
Deploy Web Apps / deploy (push) Failing after 7m46s
Queue Release Build / build-windows (push) Failing after 11m49s
Queue Release Build / build-linux (push) Successful in 34m1s
Queue Release Build / finalize (push) Has been skipped
2026-04-12 03:19:51 +02:00
587 changed files with 83722 additions and 3908 deletions

View File

@@ -0,0 +1,150 @@
---
name: document-features
description: Populate `<docs-dir>/features/<slug>.md` for one, several, or every undocumented feature area by dispatching up to 10 parallel subagents — one per feature. The agent docs directory is discovered from `AGENTS.md` — typically `agents-docs/` (the `setup-agentic-repository` default) but may be elsewhere if `--docs-dir` was used. Use whenever the user wants to document features, fill out feature docs, write up specific features (e.g. "document auth and billing"), document all undocumented features, or follow up on `find-features` discovery. This is the natural sequel to `find-features` — that skill identifies what is missing, this skill writes the docs in parallel.
metadata:
author: Olof Brogeby
url: https://github.com/brogeby
---
# document-features
Write feature documentation for the repository — one populated `<docs-dir>/features/<slug>.md` per requested feature — by dispatching subagents in parallel so an "all" run does not serialize. The agent docs directory (`<docs-dir>`) is discovered in Phase 1; it's typically `agents-docs/` but `setup-agentic-repository` may have written it somewhere else.
This skill is the documentation counterpart to `find-features`. `find-features` discovers what is missing; `document-features` is the focused worker that actually fills the template, in parallel, for the features the user names.
If the user asks "find and document everything", you can start with `find-features` to build the candidate list, then hand that list to this skill. If the user already knows which features they want (e.g. "document auth and billing"), come straight here.
---
## Phase 1 — Verify prerequisites and locate the docs directory
Confirm the repo has been initialized with the Mimas template, and discover where its agent docs actually live. `setup-agentic-repository` writes the agent doc tree to **`agents-docs/`** by default (a sibling of any human-maintained `docs/`), but `--docs-dir <dir>` can override that (e.g. `docs/agents`). Don't assume the path; discover it.
1. Read `AGENTS.md` at the repo root. Every Mimas-generated `AGENTS.md` lists its docs paths in the first block (`<docs-dir>/AGENT_WORKFLOW.md`, `<docs-dir>/AGENTS_FEATURES.md`, etc.) — the directory in those paths is the docs dir for this repo.
2. If `AGENTS.md` doesn't exist or doesn't reference the contract files, fall back to searching for `AGENTS_FEATURES.md` directly (`find . -maxdepth 3 -name AGENTS_FEATURES.md`). The directory containing it is the docs dir.
3. Confirm the files this skill needs are there:
- `<docs-dir>/AGENTS_FEATURES.md` — feature documentation contract
- `<docs-dir>/features/feature-template.md` — canonical template
- `<docs-dir>/FEATURES.md` — feature index
If any are missing, tell the user this skill is designed to run after `setup-agentic-repository` and stop. Do not scaffold them yourself.
For the rest of this skill, treat the discovered directory as `<docs-dir>` and use it wherever paths appear. **Critical:** the subagent prompt in Phase 3 is a template — substitute the actual discovered value into it before dispatching each subagent. The subagents do not run this Phase 1 themselves and will not discover the dir on their own.
Read `<docs-dir>/AGENTS_FEATURES.md` and the root `AGENTS.md` (plus any subdomain `CONTEXT.md` files) so you know the contract — what counts as an area-level doc vs. a per-service doc, and which subdomains exist. These files are authoritative; if anything in this skill drifts from them, the files win.
---
## Phase 2 — Decide what to document
The user's request usually contains the answer. Parse what they said:
- **Named features** ("document auth and billing", "write up the search feature") → take that list of slugs
- **A number** ("document 3 features", "write up the top 5") → take a numeric count of the most significant undocumented features
- **"all" / "every" / "everything"** → every undocumented feature with meaningful implementation
- **Ambiguous or empty** → use `AskUserQuestion` with the same options find-features uses (Top 5 / All / Number / Specific names) plus an "Other" free-text fallback
If the user is continuing from a recent `find-features` session, prefer the candidate list that already exists in the conversation over re-asking.
Then quickly inventory `<docs-dir>/features/`:
- Top-level `.md` files (one per documented area, ignoring `feature-template.md`)
- Subdirectories (areas with per-service docs)
- Entries in `<docs-dir>/FEATURES.md`
Filter the requested list against the inventory:
- Drop slugs that already have `<docs-dir>/features/<slug>.md`. Tell the user which ones you skipped and why
- For named features that you cannot locate in the codebase, surface them and ask whether to skip or scaffold a TODO doc. Do not invent feature areas
If the user asked for a number or "all" and you need to identify candidates yourself, scan the subdomains declared in `AGENTS.md` looking for named concepts with dedicated logic — dedicated service layer, non-trivial handler, dedicated tables/migrations, multiple endpoints, real business rules. A single empty route stub is not enough. Rank by significance and trim to the requested count.
---
## Phase 3 — Dispatch one subagent per feature, in parallel
This is the whole point of the skill. Documentation per feature is independent work — one subagent can read the code for `auth` while another reads the code for `billing`. Serializing them throws away the parallelism the harness gives you.
**Cap concurrency at 10 subagents.** If the final list has more than 10 features, dispatch the first batch of 10 in one message, wait for it to complete, then dispatch the next batch. Do not exceed 10 concurrent agents — it overwhelms tooling and the user's review can't keep up.
For each feature in the current batch, spawn one `Agent` call in the **same message** (this is what makes them run in parallel). Use the `general-purpose` subagent unless something more specific fits.
Each subagent gets a self-contained prompt — it has not seen this conversation, so include everything it needs. **Before dispatching, substitute `<docs-dir>` with the value you discovered in Phase 1** (typically `agents-docs`). The subagents do not discover the dir on their own.
```
You are documenting a single feature area for this repository. The output is one populated markdown file at `<docs-dir>/features/<slug>.md` that follows the project's feature documentation contract.
Feature slug: <slug>
One-line concept (from discovery, may be rough): <concept>
Authoritative reading order (read these first, in order):
1. `<docs-dir>/AGENTS_FEATURES.md` — the contract that governs how feature docs are written
2. `<docs-dir>/features/feature-template.md` — the canonical template. Mirror its section order and headings; do not invent your own structure
3. `AGENTS.md` and any subdomain `CONTEXT.md` files — for vocabulary and where the code lives
Your task:
- Locate this feature in the codebase. Confirm it has meaningful implementation (service layer, handler, endpoints, tables, real business rules). If it is only a stub, stop and report that back — do not write a doc for a stub
- Create `<docs-dir>/features/<slug>.md` from the template
- Fill in what you can verify from the code: overview, responsibilities, key concepts, endpoint(s), service/handler/repository paths, key types, tests location, related features
- Set `Area:` to the slug, `Status:` to `Active` for production code or `In Progress` for half-built features, and `Last updated:` to today's date in `YYYY-MM-DD`
- For sections you cannot confidently fill (performance, security review, undocumented edge cases), keep the template's prompt and add a clear `TODO:` marker. Honest gaps beat invented content
- If the feature has multiple distinct services or endpoints worth separating, also create `<docs-dir>/features/<slug>/<service>.md` files from the same template and link them from the area doc — but only when complexity actually warrants it. Do not duplicate large sections between area and per-service docs
- Do not edit `<docs-dir>/FEATURES.md` — the dispatcher will update the index once all features are written, to avoid concurrent edits to the same file
- Do not commit anything
Report back when done with:
- Path(s) of the file(s) you created
- One-line description of the feature (this will be used for the FEATURES.md entry)
- Which template sections you left as TODO
- Anything you noticed that needs human judgment
```
Substitute `<slug>`, `<concept>`, and `<docs-dir>` per feature. Keep the rest verbatim — the subagent depends on having the contract in its own context.
If features turn out to be unrelated to one another (different subdomains, different layers), there's no need to coordinate beyond avoiding the shared `<docs-dir>/FEATURES.md` file. The dispatcher updates the index after the batch returns.
---
## Phase 4 — Update the index and report back
Once a batch completes, collect each subagent's reported one-liner and add entries to `<docs-dir>/FEATURES.md` in alphabetical order, in the form:
```
- [<slug>](./features/<slug>.md) — one-line description
```
If `<docs-dir>/FEATURES.md` still contains the placeholder `_No feature areas documented yet. Add entries as you build out the system._`, remove that line as you add the first real entry.
Then, if more features remain (because the original list was >10), dispatch the next batch of up to 10 and repeat.
When everything is written, tell the user:
- Which feature docs were created (full paths), grouped by batch if there were multiple
- Which requested features were skipped, and why ("already documented at `<docs-dir>/features/<slug>.md`", "could not locate in codebase", "implementation too thin to earn an entry yet")
- Which sections in the new docs are TODOs that still need human judgment
- That `<docs-dir>/FEATURES.md` was updated and how many entries were added
- Any discrepancies you noticed between `<docs-dir>/FEATURES.md` and the filesystem
Do not commit the changes. The user reviews before committing.
---
## Why this is structured around subagents
Documenting one feature is read-heavy and largely independent of documenting another — different files, different services, different endpoints. The slow part is the reading, and parallelizing the reading is the entire reason this skill exists separately from `find-features`. Spawning ten subagents in one message and letting them work concurrently turns a 10-minute sequential run into something closer to a 12 minute parallel one, with each subagent producing a focused, faithful doc because its whole context is one feature.
The 10-agent cap is pragmatic, not theoretical: more than that and you risk rate limits, output you can't usefully review at once, and the dispatcher running out of room to track which agent owns which slug. Two batches of ten is fine; ten batches of one is the failure mode this skill is designed to avoid.
---
## What makes a good output
**Faithful to the code.** Endpoint paths, file locations, method names, table names must match what is actually in the repo. If a subagent can't find something, the right move is a `TODO:` marker, not a guess.
**Concept-first for area docs.** The area-level doc explains what the feature is *for* — responsibilities, boundaries, vocabulary. Deep implementation detail belongs in per-service docs once they exist.
**Honest about gaps.** A clear `TODO: describe rate limiting` is more useful than a fabricated rate limit policy. Future sessions can fill these in from real information.
**Proportionate.** A small CRUD endpoint with one handler does not need every section of the template. Trim or skip sections that genuinely do not apply. The template is a checklist of what *might* be relevant, not a contract that every section must be populated.
**Honors the contract.** The structure, headings, and rules in `<docs-dir>/AGENTS_FEATURES.md` and `<docs-dir>/features/feature-template.md` win every time. If this skill's instructions ever drift from those files, the files are authoritative — they live with the project and are what other agents read.

View File

@@ -0,0 +1,35 @@
{
"skill_name": "document-features",
"evals": [
{
"id": 1,
"prompt": "find-features just listed auth, billing, and search as undocumented. Please go document all three.",
"expected_output": "Skill verifies prerequisites by discovering the agent docs directory (typically agents-docs/, but may be elsewhere — read from AGENTS.md), takes the three named features, inventories <docs-dir>/features/ to confirm none are already documented, dispatches one subagent per feature in a single message (so they run in parallel), substitutes the discovered <docs-dir> value into each subagent prompt before dispatching, then writes <docs-dir>/features/auth.md, <docs-dir>/features/billing.md, <docs-dir>/features/search.md from the template, and appends three entries to <docs-dir>/FEATURES.md (alphabetical). Leaves TODO markers where content cannot be derived from the code. Does not commit.",
"files": []
},
{
"id": 2,
"prompt": "document every undocumented feature in this repo",
"expected_output": "Skill discovers the docs dir, parses 'every' as 'all', identifies undocumented feature areas with meaningful implementation, dispatches subagents in parallel batches capped at 10 per batch (with the discovered <docs-dir> substituted into each subagent prompt), writes one doc per feature, and updates <docs-dir>/FEATURES.md. If there are more than 10 features, runs a second batch after the first finishes. Reports back which features were skipped and why.",
"files": []
},
{
"id": 3,
"prompt": "write the docs for billing",
"expected_output": "Skill discovers the docs dir, treats 'billing' as a single named feature, checks <docs-dir>/features/billing.md does not already exist, locates billing in the codebase, dispatches one subagent to document it (with <docs-dir> substituted into the prompt), writes <docs-dir>/features/billing.md from the template, and adds an entry to <docs-dir>/FEATURES.md. If billing already has a doc or cannot be located, the skill surfaces that instead of silently creating something.",
"files": []
},
{
"id": 4,
"prompt": "document the top 5 undocumented features",
"expected_output": "Skill discovers the docs dir, parses '5' as a numeric limit, identifies undocumented feature areas, ranks by significance, takes the top 5, dispatches 5 subagents in parallel in a single message (each with <docs-dir> substituted), writes 5 docs from the template, and updates <docs-dir>/FEATURES.md with 5 alphabetical entries. Other candidates are listed in the final report as 'not selected this round'.",
"files": []
},
{
"id": 5,
"prompt": "this repo uses --docs-dir docs/agents for its agent docs. document the auth feature.",
"expected_output": "Skill discovers the docs dir is docs/agents/ (from AGENTS.md path references), dispatches a single subagent with docs/agents substituted into the subagent prompt template, writes docs/agents/features/auth.md, and updates docs/agents/FEATURES.md. Does not hardcode agents-docs/ or docs/features/ — uses the discovered path everywhere.",
"files": []
}
]
}

View File

@@ -0,0 +1,149 @@
---
name: find-features
description: Discover feature areas in the current repository that are not yet documented under the agent docs `features/` tree (scaffolded by `setup-agentic-repository``agents-docs/features/` by default, or wherever `--docs-dir` put it), then create populated feature docs from the canonical template. Use whenever the user wants to find undocumented features, fill out `features/`, catch up on missing feature documentation, document feature X/Y/Z, or mentions "find features". This is the natural follow-up to `setup-agentic-repository`, which scaffolds the empty `features/` tree this skill populates.
metadata:
author: Olof Brogeby
url: https://github.com/brogeby
---
# find-features
Discover feature areas in this repository that are missing from `<docs-dir>/features/`, then create a populated markdown file for each one — following the contract in `<docs-dir>/AGENTS_FEATURES.md` and the template at `<docs-dir>/features/feature-template.md`. The agent docs directory (`<docs-dir>`) is discovered in Phase 1 — it's typically `agents-docs/` but `setup-agentic-repository` may have written it somewhere else.
The Mimas template (`setup-agentic-repository`) scaffolds an empty `<docs-dir>/features/` tree. This skill is the next step — it fills it in.
---
## Phase 1 — Verify prerequisites and locate the docs directory
Confirm the repo has been initialized with the Mimas template, and discover where its agent docs actually live. `setup-agentic-repository` writes the agent doc tree to **`agents-docs/`** by default (a sibling of any human-maintained `docs/`), but `--docs-dir <dir>` can override that (e.g. `docs/agents`). Don't assume the path; discover it.
1. Read `AGENTS.md` at the repo root. Every Mimas-generated `AGENTS.md` lists its docs paths in the first block (`<docs-dir>/AGENT_WORKFLOW.md`, `<docs-dir>/AGENTS_FEATURES.md`, etc.) — the directory in those paths is the docs dir for this repo.
2. If `AGENTS.md` doesn't exist or doesn't reference the contract files, fall back to searching for `AGENTS_FEATURES.md` directly (`find . -maxdepth 3 -name AGENTS_FEATURES.md`). The directory containing it is the docs dir.
3. Confirm the files this skill needs are there:
- `<docs-dir>/AGENTS_FEATURES.md` — feature documentation contract
- `<docs-dir>/features/feature-template.md` — the canonical template
- `<docs-dir>/FEATURES.md` — the feature index
If any of those are missing, tell the user this skill is designed to run after `setup-agentic-repository` and stop. Don't try to scaffold them yourself — that is the other skill's job.
For the rest of this skill, treat the discovered directory as `<docs-dir>` and use it wherever paths appear. Don't hardcode `docs/` or `agents-docs/` in your reasoning, prompts, or report.
Read `<docs-dir>/AGENTS_FEATURES.md` and the root `AGENTS.md` (plus any subdomain `CONTEXT.md` files) so you know:
- what counts as a feature area — a named concept with dedicated logic in the codebase, identified by naming and behavior, not folder structure alone
- the split between area-level docs (`<docs-dir>/features/<area>.md`) and per-service docs (`<docs-dir>/features/<area>/<service>.md`)
- which subdomains exist and where their code lives
These files define the contract you must satisfy. The whole point of the skill is to produce docs that look like a senior engineer on this project wrote them, following the rules already agreed in `AGENTS_FEATURES.md`.
---
## Phase 2 — Inventory what is already documented
List `<docs-dir>/features/` and capture, before asking the user anything:
- Top-level `.md` files (one per documented area) — ignore `feature-template.md`, it is the template
- Subdirectories under `<docs-dir>/features/` (areas with per-service docs)
- The entries listed in `<docs-dir>/FEATURES.md`
You will use this list to filter out features that already have an area doc. Discrepancies between the filesystem and `<docs-dir>/FEATURES.md` are worth flagging in your final report, but don't block on them.
---
## Phase 3 — Ask the user what to discover
Use `AskUserQuestion` to ask how many features (or which ones) to discover. Phrase the question so it accepts a number, "all", or a free-text list of names. The tool always offers an "Other" free-text fallback, so give a few sensible presets and let the user type a specific answer when none fit.
```
questions:
- question: "How many features would you like to discover, or which ones specifically?"
header: "Scope"
multiSelect: false
options:
- label: "Top 5 (Recommended)"
description: "Discover up to 5 of the most significant undocumented feature areas."
- label: "All"
description: "Find every undocumented feature area in the codebase."
- label: "A specific number (110)"
description: "I'll tell you a number from 1 to 10."
- label: "Specific names"
description: "I'll list the feature names I want documented (e.g. 'auth, billing, search')."
```
Parse whatever they answer with — accept a bare number, the word "all" (any case), or a comma- or space-separated list. Phrases like "find feature x, y, z" or "auth and billing" should yield `["x", "y", "z"]` and `["auth", "billing"]` respectively. Don't be strict about format; pull out the names.
If the user names features you can't locate in the codebase, surface that and ask whether to skip them or create scaffolded TODO docs for them. Do not silently invent them.
---
## Phase 4 — Discover undocumented feature areas
Scan the codebase for feature areas using the definition from `<docs-dir>/AGENTS_FEATURES.md`. Start from the subdomains declared in the root `AGENTS.md` — if there are several large subdomains, **launch one subagent per subdomain in parallel** so discovery doesn't serialize.
For each candidate feature, capture:
- **Slug** — kebab-case, ideally matching how the area is referenced in code or routes (e.g. `auth`, `billing`, `recruitment-content`)
- **One-sentence concept** — what the feature does, in user-facing terms
- **Where the code lives** — routes, services, handlers, components, modules
- **Significance signal** — at least one of: dedicated service layer, non-trivial handler, dedicated DB tables/migrations, multiple endpoints, real business rules. A single empty route stub is not enough
- **Whether it is already documented** — does `<docs-dir>/features/<slug>.md` exist already?
Then filter:
- Drop anything that already has an area-level doc
- Drop stubs and scaffolding — `<docs-dir>/AGENTS_FEATURES.md` says an entry must have meaningful implementation. Err on the side of leaving thin features out and noting them as "not yet earning an entry"
Rank what is left by significance (surface area, number of endpoints, depth of business logic), then trim to what the user asked for:
- **Number** → take the top N from the ranked list
- **"all"** → take everything that survived the filter
- **Named features** → take only those, matched by slug or close fuzzy match, warning about any that didn't match
---
## Phase 5 — Create the feature docs
For each chosen feature:
1. Read `<docs-dir>/features/feature-template.md` once and use it as the structural template — section order, headings, prompts. Do not invent your own structure.
2. Create `<docs-dir>/features/<slug>.md` populated from the template:
- Fill in what you can verify from the code: overview, responsibilities, key concepts, API endpoint(s), service/handler/repository paths, key types, tests location, related features
- Set **`Area:`** to the slug, **`Status:`** to `Active` for production code or `In Progress` for half-built features, and **`Last updated:`** to today's date in `YYYY-MM-DD`
- For sections you cannot confidently fill (performance characteristics, security review, edge cases nobody has documented), keep the template's prompt and add a clear TODO marker. Leaving an honest gap is better than inventing content
3. If the feature has multiple distinct services or endpoints worth separating, also create `<docs-dir>/features/<slug>/` and seed per-service docs (`<docs-dir>/features/<slug>/<service>.md`) from the same template, then link them from the area doc. Only do this when complexity actually warrants it — the contract says don't duplicate large sections between area and per-service docs
After writing each file, add an entry to `<docs-dir>/FEATURES.md` in alphabetical order, in the form:
```
- [<slug>](./features/<slug>.md) — one-line description
```
If `<docs-dir>/FEATURES.md` still contains the placeholder `_No feature areas documented yet. Add entries as you build out the system._`, remove that line as you add the first real entry.
---
## Phase 6 — Report back
Tell the user:
- Which feature docs were created (full paths)
- Which candidates were considered but rejected, and why ("too thin to earn an entry yet", "matches existing doc <name>", etc.)
- Which sections in the new docs are TODOs that still need human judgment (performance, security, business rules, error scenarios)
- Whether `<docs-dir>/FEATURES.md` was updated, and any discrepancies you noticed between it and the filesystem
Do not commit the changes. The user reviews before committing.
---
## What makes a good output
**Faithful to the code.** Endpoint paths, file locations, method names, table names must match what is actually in the repo. If you cannot find something, leave the template prompt in place with a TODO — do not guess.
**Concept-first for area docs.** The area-level doc explains what the feature is *for* — responsibilities, boundaries, vocabulary. Implementation detail belongs in per-service docs once they exist.
**Honest about gaps.** A clear `TODO: describe rate limiting` is more useful than a fabricated rate limit policy. Future sessions can fill these in from real information.
**Proportionate.** A small CRUD endpoint with one handler does not need every section of the template. Trim or skip sections that genuinely do not apply (no auth, no caching, no migrations, no feature flags). The template is a checklist of what *might* be relevant, not a contract that every section must be populated.
**Honors the contract.** The structure, headings, and rules in `<docs-dir>/AGENTS_FEATURES.md` and `<docs-dir>/features/feature-template.md` win every time. If this skill's instructions ever drift from those files, the files are authoritative — they live with the project and are what other agents read.

View File

@@ -0,0 +1,35 @@
{
"skill_name": "find-features",
"evals": [
{
"id": 1,
"prompt": "I just ran /setup-agentic-repository on this repo and the features folder is empty. Can you find the most important features and write up docs for them?",
"expected_output": "Skill verifies prerequisites by discovering the agent docs directory (reading AGENTS.md or searching for AGENTS_FEATURES.md — typically agents-docs/ but may be elsewhere), asks how many/which features (with Top 5 / All / Number / Names options), scans the codebase, creates <docs-dir>/features/<slug>.md files from the template for the chosen features, and appends entries to <docs-dir>/FEATURES.md (alphabetical). Honestly leaves TODO markers where it cannot derive content from the code.",
"files": []
},
{
"id": 2,
"prompt": "we have some feature docs already but I think auth and billing are missing — can you check and add what's missing?",
"expected_output": "Skill discovers the docs dir, inventories the existing <docs-dir>/features/, treats the user's free-text answer 'auth and billing' as the named list, verifies both exist in the codebase (skipping or flagging any that don't), and creates only the missing area docs while leaving already-documented features alone.",
"files": []
},
{
"id": 3,
"prompt": "find every undocumented feature in this repo and document them all",
"expected_output": "Skill interprets 'every' as 'all', discovers every feature area with meaningful implementation that lacks an area doc in the discovered <docs-dir>/features/, filters out stubs, ranks by significance, and creates one doc per area plus <docs-dir>/FEATURES.md entries. Reports back rejected candidates with reasons.",
"files": []
},
{
"id": 4,
"prompt": "find 3 features that aren't documented yet",
"expected_output": "Skill parses '3' as a numeric limit, discovers undocumented features in the discovered docs dir, takes the top 3 by significance, and creates docs and <docs-dir>/FEATURES.md entries for exactly those three. Other candidates are listed in the final report as 'not selected this round'.",
"files": []
},
{
"id": 5,
"prompt": "the docs are under agents-docs/ in this repo, find any feature areas we haven't written up yet",
"expected_output": "Skill reads AGENTS.md (or falls back to searching for AGENTS_FEATURES.md), confirms the docs dir is agents-docs/, uses agents-docs/AGENTS_FEATURES.md as the contract and agents-docs/features/feature-template.md as the template, and writes new docs into agents-docs/features/. Does not hardcode 'docs/' anywhere in the output.",
"files": []
}
]
}

View File

@@ -17,6 +17,13 @@ jobs:
- name: Checkout - name: Checkout
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@v4
- name: Restore npm cache
uses: https://github.com/actions/cache@v4
with:
path: ~/AppData/Local/npm-cache
key: npm-windows-${{ hashFiles('package-lock.json', 'website/package-lock.json') }}
restore-keys: npm-windows-
- name: Install root dependencies - name: Install root dependencies
env: env:
NODE_ENV: development NODE_ENV: development

View File

@@ -48,17 +48,30 @@ jobs:
- name: Checkout - name: Checkout
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@v4
- name: Restore npm cache
uses: https://github.com/actions/cache@v4
with:
path: /root/.npm
key: npm-linux-${{ hashFiles('package-lock.json', 'server/package-lock.json', 'docs-site/package-lock.json') }}
restore-keys: npm-linux-
- name: Restore Electron cache
uses: https://github.com/actions/cache@v4
with:
path: |
/root/.cache/electron
/root/.cache/electron-builder
key: electron-linux-${{ hashFiles('package.json') }}
restore-keys: electron-linux-
- name: Install dependencies - name: Install dependencies
env: env:
NODE_ENV: development NODE_ENV: development
run: | run: |
apt-get update && apt-get install -y --no-install-recommends zip
npm ci npm ci
cd server && npm ci cd server && npm ci
cd ../docs-site && npm ci
- name: Install zip utility
run: |
apt-get update
apt-get install -y zip
- name: Set CI release version - name: Set CI release version
run: > run: >
@@ -71,6 +84,7 @@ jobs:
cd toju-app cd toju-app
npx ng build --configuration production --base-href='./' npx ng build --configuration production --base-href='./'
cd .. cd ..
npm run build:docs
npx --package typescript tsc -p tsconfig.electron.json npx --package typescript tsc -p tsconfig.electron.json
cd server cd server
node ../tools/sync-server-build-version.js node ../tools/sync-server-build-version.js
@@ -108,12 +122,29 @@ jobs:
- name: Checkout - name: Checkout
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@v4
- name: Restore npm cache
uses: https://github.com/actions/cache@v4
with:
path: ~/AppData/Local/npm-cache
key: npm-windows-${{ hashFiles('package-lock.json', 'server/package-lock.json', 'docs-site/package-lock.json') }}
restore-keys: npm-windows-
- name: Restore Electron cache
uses: https://github.com/actions/cache@v4
with:
path: |
~/AppData/Local/electron/Cache
~/AppData/Local/electron-builder/Cache
key: electron-windows-${{ hashFiles('package.json') }}
restore-keys: electron-windows-
- name: Install dependencies - name: Install dependencies
env: env:
NODE_ENV: development NODE_ENV: development
run: | run: |
npm ci npm ci
npm ci --prefix server npm ci --prefix server
npm ci --prefix docs-site
- name: Set CI release version - name: Set CI release version
run: > run: >
@@ -126,6 +157,7 @@ jobs:
Push-Location "toju-app" Push-Location "toju-app"
npx ng build --configuration production --base-href='./' npx ng build --configuration production --base-href='./'
Pop-Location Pop-Location
npm run build:docs
npx --package typescript tsc -p tsconfig.electron.json npx --package typescript tsc -p tsconfig.electron.json
Push-Location server Push-Location server
node ../tools/sync-server-build-version.js node ../tools/sync-server-build-version.js
@@ -166,6 +198,7 @@ jobs:
Copy-Item -Path (Join-Path $projectRoot 'package.json') -Destination (Join-Path $electronBuilderWorkspace 'package.json') -Force Copy-Item -Path (Join-Path $projectRoot 'package.json') -Destination (Join-Path $electronBuilderWorkspace 'package.json') -Force
Copy-Item -Path (Join-Path $projectRoot 'package-lock.json') -Destination (Join-Path $electronBuilderWorkspace 'package-lock.json') -Force Copy-Item -Path (Join-Path $projectRoot 'package-lock.json') -Destination (Join-Path $electronBuilderWorkspace 'package-lock.json') -Force
Invoke-RoboCopy (Join-Path $projectRoot 'dist') (Join-Path $electronBuilderWorkspace 'dist') Invoke-RoboCopy (Join-Path $projectRoot 'dist') (Join-Path $electronBuilderWorkspace 'dist')
Invoke-RoboCopy (Join-Path $projectRoot 'docs-site/build') (Join-Path $electronBuilderWorkspace 'docs-site/build')
Invoke-RoboCopy (Join-Path $projectRoot 'images') (Join-Path $electronBuilderWorkspace 'images') Invoke-RoboCopy (Join-Path $projectRoot 'images') (Join-Path $electronBuilderWorkspace 'images')
Invoke-RoboCopy (Join-Path $projectRoot 'node_modules') (Join-Path $electronBuilderWorkspace 'node_modules') Invoke-RoboCopy (Join-Path $projectRoot 'node_modules') (Join-Path $electronBuilderWorkspace 'node_modules')
@@ -217,9 +250,6 @@ jobs:
- name: Checkout - name: Checkout
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@v4
- name: Install dependencies
run: npm ci --omit=dev
- name: Download previous manifest - name: Download previous manifest
env: env:
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}

9
.gitignore vendored
View File

@@ -16,6 +16,7 @@ yarn-error.log
dist-electron dist-electron
node_modules/* node_modules/*
*server/node_modules/* *server/node_modules/*
/docs-site/node_modules/
.angular .angular
# IDEs and editors # IDEs and editors
.idea/ .idea/
@@ -39,6 +40,8 @@ node_modules/*
.sass-cache/ .sass-cache/
/connect.lock /connect.lock
/coverage /coverage
/docs-site/.docusaurus/
/docs-site/build/
/libpeerconnection.log /libpeerconnection.log
testem.log testem.log
/typings /typings
@@ -58,5 +61,9 @@ Thumbs.db
/server/data/variables.json /server/data/variables.json
dist-server/* dist-server/*
AGENTS.md
doc/** doc/**
metoyou.sqlite*
metoyou.sqlite
vitest/

101
AGENTS.md Normal file
View File

@@ -0,0 +1,101 @@
# AGENTS.md
Read these files at the start of every session before doing any work:
1. `agents-docs/AGENT_WORKFLOW.md` — workflow and operating rules
2. `agents-docs/LESSONS.md` — durable rules learned from past corrections; apply any that match this session's work
3. `agents-docs/AGENTS_FEATURES.md` — when and how to update feature docs
4. `agents-docs/FEATURES.md` — feature index
5. `agents-docs/ENGINEERING.md` — engineering standards
6. `agents-docs/CONTEXT-MAP.md` — index of bounded contexts in this repo
Reference on-demand (when the workflow triggers them — see `agents-docs/AGENT_WORKFLOW.md` §§ 45):
- `agents-docs/AGENTS_CONTEXT.md` — contract for updating `CONTEXT.md` / `CONTEXT-MAP.md`
- `agents-docs/AGENTS_ADRS.md` — contract for writing architecture decision records
When working in a subdomain, also read its `CONTEXT.md` first:
- Product client (Angular 21): `toju-app/CONTEXT.md`
- Desktop shell (Electron main + preload): `electron/CONTEXT.md`
- Signaling server (Express + WebSocket): `server/CONTEXT.md`
- End-to-end tests (Playwright): `e2e/CONTEXT.md`
- Marketing site (Angular 19): `website/CONTEXT.md`
- Application documentation (Docusaurus): `docs-site/CONTEXT.md`
---
MetoYou (also called Toju) is a desktop-first, P2P Discord-style chat application managed as an npm-workspaces monorepo. It bundles an Angular 21 product client, an Electron 39 desktop shell with TypeORM + sql.js for local persistence, a small Node/TypeScript Express signaling server with WebSocket-based realtime, a Playwright end-to-end suite, an Angular 19 marketing site, and a Docusaurus app/plugin documentation site that ships inside the Electron build. Voice and screen-share are WebRTC, with RNNoise denoising via a WASM audio worklet.
## CRITICAL — Non-negotiable rules for all agents
### Test-Driven Development (MANDATORY)
**Write tests before implementation code.**
When creating or changing anything:
1. STOP — do not write implementation first
2. Write failing tests (RED)
3. Run tests and confirm failure (`npm run test` for the product client; `npm run test:e2e` for end-to-end; place spec files colocated with source, suffix `.spec.ts`)
4. Write minimal code to pass tests (GREEN)
5. Refactor while keeping tests green
This applies to all code — Angular components and services, NgRx effects/reducers, Electron IPC handlers, server CQRS handlers, websocket message handlers, plugin runtime, and domain logic. If the code lives in a package without a configured test runner (server, website, docs-site), surface that gap before adding logic there.
### Lint correctness (MANDATORY)
Before completing any task:
1. Run `npm run lint` from the repo root (ESLint 9 flat config in `eslint.config.js` covers every package)
2. Fix all errors
3. Do not consider work complete until it exits with code 0
### Type / build correctness (MANDATORY)
Type checks live in build scripts:
- Product client (`toju-app/`): `npm run build` (Angular CLI runs `tsc` with strict settings)
- Electron (`electron/`): `npm run build:electron` (invokes `tsc -p tsconfig.electron.json`)
- Server (`server/`): `cd server && npm run build` (invokes `tsc`)
If your change touches one of these packages, run the corresponding build and ensure it exits 0 before marking work complete.
## Most important rule
After any change that affects API contracts, schemas, invariants, workflows, or major behavior: update the relevant `agents-docs/features/<slug>.md` as part of the same task — not as a follow-up. New feature area → create `agents-docs/features/<slug>.md` and add an entry to `agents-docs/FEATURES.md` (alphabetical).
The product client already maintains per-domain READMEs under `toju-app/src/app/domains/<name>/README.md`. When the change is fully internal to one of those bounded contexts and its surface stays the same, the domain README is the right place to update; cross-context contracts (websocket envelopes, IPC channels, server routes, plugin manifests) belong in `agents-docs/features/`.
## Structure of further instructions
- **Agent workflow & operating rules:** `agents-docs/AGENT_WORKFLOW.md`
- **Agent lessons (durable cross-session rules):** `agents-docs/LESSONS.md`
- **Engineering standards:** `agents-docs/ENGINEERING.md`
- **Feature documentation contract:** `agents-docs/AGENTS_FEATURES.md`
- **CONTEXT documentation contract:** `agents-docs/AGENTS_CONTEXT.md`
- **ADR contract:** `agents-docs/AGENTS_ADRS.md`
- **Feature index:** `agents-docs/FEATURES.md`
- **Feature docs:** `agents-docs/features/`
- **Architecture decisions:** `agents-docs/adr/`
- **Context map:** `agents-docs/CONTEXT-MAP.md`
- **Product-client domain:** `toju-app/CONTEXT.md`
- **Desktop-shell domain:** `electron/CONTEXT.md`
- **Server domain:** `server/CONTEXT.md`
- **E2E suite domain:** `e2e/CONTEXT.md`
- **Marketing-site domain:** `website/CONTEXT.md`
- **App-docs domain:** `docs-site/CONTEXT.md`
Keep this file minimal. Do not duplicate detailed rules here.
## Completion checklist
Before marking work complete:
- [ ] Tests written before implementation
- [ ] All tests passing (`npm run test`, plus `npm run test:e2e` if behavior is user-visible)
- [ ] `npm run lint` passes
- [ ] Affected package builds: `npm run build` / `npm run build:electron` / `cd server && npm run build`
- [ ] Naming conventions followed (kebab-case files; domain `*.rules.ts` / `*.model.ts` / `*.component.ts` suffixes)
- [ ] Errors handled
- [ ] Feature docs updated if contract/schema/invariant changed (see `agents-docs/AGENTS_FEATURES.md`)
- [ ] `CONTEXT.md` updated if a domain term was resolved or introduced (see `agents-docs/AGENTS_CONTEXT.md`)
- [ ] ADR written if a hard-to-reverse decision was made (see `agents-docs/AGENTS_ADRS.md`)
- [ ] Lesson recorded in `agents-docs/LESSONS.md` if this session produced a correction, revert, or hidden constraint (see triggers in `agents-docs/AGENT_WORKFLOW.md`)
- [ ] PR opened with summary and linked issues (`Fixes #<n>` / `Relates to #<n>`)
- [ ] Gitea Workflows checks passing

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
Read AGENTS.md at the root of this repository at the start of every session before doing any work. It links to all other agent instruction files.

169
README.md
View File

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

View File

@@ -0,0 +1,89 @@
# Agent Instructions: Architecture Decision Records (ADRs)
Architectural decisions live in **`agents-docs/adr/`** as numbered Markdown files (`NNNN-slug.md`).
This document defines how agents must detect, document, and maintain architectural decisions as the codebase grows.
> This file is part of the agent instruction infrastructure.
> Do NOT create, delete, or modify this file unless explicitly instructed.
---
## What an ADR is
A short record of an architectural decision that future engineers (and agents) will need context for. The format is Nygard short form:
- Title and number (`ADR-NNNN: <slug>`).
- Required: 13 sentences each covering **Context** (why this came up), **Decision** (what was chosen), and **Rationale** (why this option over alternatives).
- Conventional: `Status` (usually `Accepted` for new ADRs; `Superseded by ADR-MMMM` once overturned).
- Optional: `Considered Options`, `Consequences` — add only when they genuinely help. Most ADRs won't need them.
See `agents-docs/adr/0001-record-architectural-decisions.md` for the canonical example — a minimal four-section ADR that matches the typical shape.
The value is in recording **that a decision was made** and **why** — not in completing formal sections.
---
## ADR Contract (MANDATORY)
### When to write an ADR
The canonical criteria — the 3-criteria gate — live in `agents-docs/AGENT_WORKFLOW.md` § 5 ADR upkeep. Read those before writing. In short: write an ADR only when the decision is **hard to reverse**, **surprising without context**, and the **result of genuine trade-offs**. If any of the three is missing, don't.
Suitable topics: architectural patterns, integration approaches, significant technology selections, scope boundaries, intentional deviations from standard practices, non-obvious rejections of alternatives.
### Read before crossing decision boundaries
Before non-trivial changes in an area, scan `agents-docs/adr/` for decisions that touch it. If your work would contradict an existing ADR:
- **Surface it explicitly**, don't silently override. Phrase it as: "_Contradicts ADR-NNNN (slug) — but worth reopening because…_"
- If the contradiction is intentional, write a new ADR that supersedes the old one (see below).
### Write the ADR in the same turn as the decision
When the 3-criteria gate is met, write the ADR before reporting the task done. The `AGENTS.md` completion checklist has a line for this; don't tick the box without it.
### Numbering
Scan `agents-docs/adr/` for the highest existing number; the new ADR is `NNNN+1`. Use 4-digit zero-padded numbers (`0001`, `0002`, …).
Slugs are kebab-case and describe the decision concisely: `0042-postgres-for-write-model.md`, `0043-event-sourced-orders.md`.
### Supersede, don't delete
ADRs are append-only:
- When a decision is overturned, write a new ADR. The old one stays.
- Add `Superseded by ADR-NNNN` near the top of the old ADR.
- Add `Supersedes ADR-MMMM` near the top of the new one.
- Never delete or rewrite history.
---
## Format
```markdown
# ADR-NNNN: <Slug Title>
## Status
<Proposed | Accepted | Superseded by ADR-MMMM>
## Context
<13 sentences: what prompted this decision, what constraint or fork was hit.>
## Decision
<13 sentences: what was chosen, plainly stated.>
## Rationale
<13 sentences: why this option over the alternatives.>
<!-- Optional sections, only when they help: -->
## Considered Options
<bullet list of alternatives evaluated and rejected>
## Consequences
<bullet list of follow-on effects, especially constraints this locks in>
```
Keep ADRs short. Three sentences per section beats three paragraphs.

View File

@@ -0,0 +1,81 @@
# Agent Instructions: CONTEXT.md & CONTEXT-MAP.md
Domain documentation lives in **`CONTEXT.md`** files co-located with the code they describe:
- **Single-context repo:** one `CONTEXT.md` at the root (or at the top of the single subdomain).
- **Multi-context repo:** one `CONTEXT.md` per subdomain (e.g. `src/CONTEXT.md`, `frontend/CONTEXT.md`), indexed by `agents-docs/CONTEXT-MAP.md`.
This document defines how agents must detect, document, and maintain domain knowledge as the codebase grows.
> This file is part of the agent instruction infrastructure.
> Do NOT create, delete, or modify this file unless explicitly instructed.
---
## What `CONTEXT.md` is for
A subdomain's `CONTEXT.md` is a **domain artefact**, not an agent-rule file. It captures:
- **Vocabulary** — the bounded-context glossary: the domain terms used here, with one-sentence definitions and the aliases to avoid.
- **Relationships** — how the domain terms connect (cardinality, ownership).
- **Boundaries / IO** — what this subdomain exposes externally and consumes from other subdomains.
- **Invariants** — rules that always hold within this subdomain.
- **Flagged ambiguities** — terms still in dispute, with proposed resolutions.
Agent-procedural rules (TDD, typecheck, formatter) live in `/AGENTS.md` and `agents-docs/ENGINEERING.md` — never in `CONTEXT.md`.
Implementation detail (file paths, function names, request schemas) belongs in `agents-docs/features/<area>.md` — never in `CONTEXT.md`.
## What `CONTEXT-MAP.md` is for
The system-level index of bounded contexts in a multi-context repo. One row per subdomain — name, one-line purpose, public surface, link to its `CONTEXT.md`. Plus relationships between contexts (upstream/downstream, shared types, events).
Only exists when ≥2 subdomains have their own `CONTEXT.md`. Single-context repos skip it.
---
## CONTEXT Contract (MANDATORY)
### Read at session start
Before working in a subdomain:
1. Read that subdomain's `CONTEXT.md`. If `agents-docs/CONTEXT-MAP.md` exists, start there to locate the right one.
2. If your change couples two subdomains (shared types, cross-context events), read both `CONTEXT.md`s.
3. Skip files that don't exist. **Proceed silently** — don't flag absence; producer triggers create them lazily.
### Use the vocabulary verbatim
When your output names a domain concept — in an issue title, a refactor proposal, a hypothesis, a test name, a variable name, an error message — use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
### Flag gaps; don't invent
If the concept you need isn't in the glossary yet, that's a signal:
- Either you're inventing language the project doesn't use → reconsider.
- Or there's a real gap → add it (see triggers below). Don't silently coin a new term.
### Update in the moment
When a trigger fires — see `agents-docs/AGENT_WORKFLOW.md` § 4 CONTEXT.md upkeep for the canonical trigger list — update the relevant `CONTEXT.md` in the same turn, before reporting work done. The triggers cover term resolutions, user corrections to terminology, new concepts introduced by features, and self-caught synonym invention.
### Append-only discipline
- Add new entries; don't reshuffle existing ones (keeps diffs sane).
- If a term changes meaning, supersede it with a clarifying entry — don't silently rewrite history.
- If `Flagged ambiguities` gets resolved, move the resolution into the main vocabulary table and remove the flag.
### Multi-context: keep the map current
When adding a new subdomain `CONTEXT.md`, add a row to `agents-docs/CONTEXT-MAP.md` in the same task. When the public surface or upstream/downstream relationships change, update the map.
---
## Format
The format of an entry is documented at the top of each `CONTEXT.md` so it self-describes. Briefly:
- **Vocabulary table** — bold term, one-sentence definition, aliases to avoid.
- **Relationships** — bullet list using bold terms and cardinality ("A **TermA** belongs to exactly one **TermB**").
- **Boundaries / IO** — `Exposes:` and `Consumes:` bullets.
- **Invariants** — bullet list of constraints that always hold.
- **Flagged ambiguities** — terms still in dispute, with proposed resolutions.

View File

@@ -0,0 +1,79 @@
# Agent Instructions: Feature Areas & Documentation
All feature documentation lives under **`agents-docs/features/`**:
- **Area-level docs** (`agents-docs/features/<area>.md`): concept-first overview of a feature area — responsibilities, boundaries, key concepts.
- **Per-service docs** (`agents-docs/features/<area>/<service>.md`): API contracts, request/response schemas, implementation details, changelogs.
This document defines how agents must detect, document, and maintain feature knowledge as the codebase grows.
> This file is part of the agent instruction infrastructure.
> Do NOT create, delete, or modify this file unless explicitly instructed.
---
## What is a feature area?
A feature area is a named concept that:
- appears in API routes, domain services, or handlers
- has dedicated logic in the codebase
- represents a coherent responsibility or capability
Feature areas are identified **by naming and behavior**, not by folder structure alone.
---
## Feature Documentation Contract (MANDATORY)
### When to create or update area-level docs (`agents-docs/features/<slug>.md`)
- New feature area introduced → create `agents-docs/features/<slug>.md` and add to `agents-docs/FEATURES.md` (alphabetical).
- Changes to **responsibilities, boundaries, workflows, or high-level behavior** → update the relevant area doc in the same task.
### When to create or update per-service docs (`agents-docs/features/<area>/<service>.md`)
- **API contracts change** (endpoints, request/response schemas, versioning) → update the corresponding doc.
- **New API or capability** → create a per-service doc and link it from the area doc.
- **Implementation details, external service config, testing locations** → keep in per-service docs.
### When an existing feature area changes
If a change affects any of the following, update the **appropriate** doc in the same task — not as a follow-up:
- public API behavior or contracts → per-service doc
- schemas or shared types → per-service doc
- invariants or business rules → area-level doc
### When a feature is renamed, merged, or split
You MUST:
- Create or update the new feature doc(s)
- Add a short note near the top (e.g. "Renamed from …" or "Merged from …")
- Update `agents-docs/FEATURES.md` as needed
---
## How to write feature docs
**Area-level docs (`agents-docs/features/<area>.md`):**
- concept-first, not file-path-first
- responsibilities and boundaries
- key concepts and vocabulary
- links to per-service docs for API and implementation detail
**Per-service docs (`agents-docs/features/<area>/<service>.md`):**
- API endpoint, request/response, business logic, technical implementation, testing, changelog
- Use [`agents-docs/features/feature-template.md`](./features/feature-template.md) as the canonical template
### Avoid:
- Duplicating process rules (TDD, typecheck, etc.) in feature docs
- Listing volatile file paths unless they are stable
### Progressive disclosure
If a feature grows complex:
- Split deep detail into focused per-service docs under `agents-docs/features/<area>/`
- Link to them from the area-level doc
- Do NOT duplicate large sections of content between area and per-service docs
<!-- If you're reading this, you owe Olof a coffee. -->

View File

@@ -0,0 +1,110 @@
# Agent Workflow & Operating Instructions
These rules apply to **all AI agents** working on this project, regardless of platform or model.
Read this file at the start of every session.
---
## Workflow Orchestration
### 1. Plan Mode Default
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
- If something goes sideways, STOP and re-plan immediately — don't keep pushing
- Use plan mode for verification steps, not just building
- Write detailed specs upfront to reduce ambiguity
### 2. Subagent Strategy
- Use subagents liberally to keep the main context window clean
- Offload research, exploration, and parallel analysis to subagents
- For complex problems, throw more compute at it via subagents
- One task per subagent for focused execution
### 3. Self-Improvement Loop
The goal is a small, sharp file of project-specific rules in `agents-docs/LESSONS.md` that future sessions read and apply. The format of a lesson is defined at the top of `agents-docs/LESSONS.md` — read it before writing one.
**Read at session start.** Open `agents-docs/LESSONS.md` and apply any rules that match the work you're about to do. This is non-optional; the file exists so the same mistake isn't made twice.
**Triggers — record a lesson when any of these happen.** Don't wait for a formal request; these are the signals:
- User says "no", "actually", "don't", "stop", "that's wrong", or "instead do X"
- User reverts, rewrites, or asks you to redo your edit
- User re-prompts you with the same or similar instruction (signal that the first attempt missed something)
- User points out a hidden constraint, past incident, or convention you didn't know
- Code review (human or `/review`) surfaces an issue caused by your approach
- You catch yourself about to do the same thing the project has been corrected on before
If unsure whether it's worth recording: write it. Sharper is better than missing, and grooming the file is cheap.
**Write before reporting done.** A session that produced a correction must produce a lesson — record it in the same turn the work is completed, not "later". The `AGENTS.md` completion checklist has a line for this; don't tick the box without it.
**Groom periodically.** When `agents-docs/LESSONS.md` passes ~20 entries, propose consolidations to the user — merge duplicates, delete rules that no longer apply, shorten anything vague.
### 4. CONTEXT.md upkeep
Read `CONTEXT.md` (or `agents-docs/CONTEXT-MAP.md` → per-subdomain `CONTEXT.md`) when working in a subdomain. Use its vocabulary verbatim **where defined** in code, tests, issues, and commits. If a needed term isn't in the glossary, treat it as a trigger (see below) rather than silently inventing a synonym; the full contract lives in `agents-docs/AGENTS_CONTEXT.md`.
**Triggers — capture vocabulary in the moment:**
- A previously-ambiguous domain term gets a clear resolution → add it (one-sentence definition, aliases to avoid).
- User corrects your terminology → record the correct term; mark the wrong one as an alias to avoid.
- A new feature introduces a concept absent from the glossary → add it before claiming the feature done.
- You catch yourself inventing a synonym because the right term isn't there → flag the gap; don't silently coin a new term.
**Write before reporting done.** Update the relevant `CONTEXT.md` in the same turn the trigger fires. Append-only — add new entries, don't reshuffle existing ones. The format is documented at the top of each `CONTEXT.md`. See `agents-docs/AGENTS_CONTEXT.md` for the full contract.
### 5. ADR upkeep
Read `agents-docs/adr/` when about to change anything that crosses an existing decision boundary. If your work would contradict an ADR, surface it explicitly — never silently override.
**Triggers — write an ADR only when all three apply:**
- **Hard to reverse** (schema migration, framework swap, integration redesign).
- **Surprising without context** (future engineers will question the approach).
- **Result of genuine trade-offs** (real alternatives existed and you chose deliberately).
If all three apply: write the ADR in the same turn as the decision. Next number (4-digit zero-padded), kebab-case slug, Nygard short form — see `agents-docs/adr/0001-record-architectural-decisions.md` for the canonical example and `agents-docs/AGENTS_ADRS.md` for the contract. If any of the three is missing: don't write one.
**Supersede, don't delete.** Overturned decisions get a new ADR; the old one stays with a `Superseded by ADR-NNNN` note.
### 6. Verification Before Done
- Never mark a task complete without proving it works
- Diff behavior between main and your changes when relevant
- Ask yourself: "Would a staff engineer approve this?"
- Run tests, check logs, demonstrate correctness
### 7. Demand Elegance (Balanced)
- For non-trivial changes: pause and ask "is there a more elegant way?"
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
- Skip this for simple, obvious fixes — don't over-engineer
- Challenge your own work before presenting it
### 8. Autonomous Bug Fixing
- When given a bug report: just fix it. Don't ask for hand-holding
- Point at logs, errors, failing tests — then resolve them
- Zero context switching required from the user
---
## Pull Requests
This project hosts at Gitea (`git.azaaxin.com/myxelium/Toju`). Gitea PRs and issues use GitHub-style syntax.
- Create a feature branch for every change: `<type>/<short-description>` (e.g. `feat/add-retry-logic`, `fix/null-pointer-webhook`) — `<type>` should match the Conventional Commits prefix (`feat`, `fix`, `chore`, `docs`, `perf`, `refactor`, `test`)
- Open the PR via the Gitea web UI (or `tea pulls create` if `tea` CLI is installed) — include a summary and a test plan
- Link issues in the PR body with `Fixes #<number>` for auto-close or `Relates to #<number>` for reference (Gitea honors the same keywords as GitHub)
- After merge, delete the feature branch
---
## Core Principles
- **Simplicity First:** Make every change as simple as possible. Impact minimal code.
- **No Laziness:** Find root causes. No temporary fixes. Senior developer standards.
- **Minimal Impact:** Changes should only touch what's necessary. Avoid introducing bugs.

View File

@@ -0,0 +1,30 @@
# Context Map
Bounded contexts in this system. Before working in a subdomain, read its `CONTEXT.md`. See `agents-docs/AGENTS_CONTEXT.md` for the contract.
## Contexts
| Context | Purpose | Public surface | CONTEXT.md |
|---------|---------|----------------|------------|
| **toju-app** | Angular 21 product client — UI, NgRx state, per-domain rules and services for chat, voice, screen-share, plugins, theming | Window-hosted Angular bundle; consumes Electron `window.api` (preload bridge) and the server WebSocket; serves the user-facing experience | `toju-app/CONTEXT.md` |
| **electron** | Desktop shell — main process, preload bridge, IPC handlers, local SQLite persistence, plugin sandbox, OS integrations | `window.api.*` surface exposed to the renderer via the preload; main-process IPC channel names; CQRS handlers; TypeORM entities in `electron/entities/` | `electron/CONTEXT.md` |
| **server** | Signaling server — REST routes for server directory + auth, WebSocket realtime, CQRS handlers, TypeORM persistence | HTTP routes under `server/src/routes/`; WebSocket envelopes under `server/src/websocket/`; server-directory API | `server/CONTEXT.md` |
| **e2e** | Playwright suite — end-to-end coverage of the product client running against a real Electron build and signaling server | No public surface — observer/verifier of the system | `e2e/CONTEXT.md` |
| **website** | Angular 19 marketing site — public-facing landing pages, screenshots, download links | Static SSR/CSR bundle deployed independently of the product app | `website/CONTEXT.md` |
| **docs-site** | Docusaurus app — application and plugin author documentation served by the Electron Local API | Static bundle at `docs-site/build/`, mounted by Electron's local HTTP server for in-app docs | `docs-site/CONTEXT.md` |
## Relationships
- **toju-app** is downstream of **electron** via the `window.api` preload bridge. The renderer cannot reach Node, the filesystem, or SQLite directly — every privileged operation goes through an IPC channel defined in `electron/`.
- **toju-app** is downstream of **server** via the WebSocket envelope contract and the REST server-directory API. Envelope shape changes require coordinated edits to both sides.
- **electron** owns the **local** persistence layer (per-user TypeORM + sql.js database). **server** owns the **shared** persistence layer (signaling state, server-directory entries, auth artifacts). They do not share entities — the wire format is the contract.
- **electron** hosts **docs-site** at runtime: the Local API server inside the desktop app mounts the prebuilt Docusaurus bundle so plugin authors and end users can browse docs offline. Building docs-site is a prerequisite of `npm run build:all`.
- **e2e** depends on **toju-app**, **electron**, and **server** simultaneously — tests boot the full desktop stack against a real signaling server. Treat E2E as the integration boundary that proves the contracts above are aligned.
- **website** is independent of the runtime stack. It shares no code or schemas with the product app; it links out to release artifacts produced by Gitea Workflows.
- **toju-app** plugin runtime (under `toju-app/src/app/domains/plugins/`) consumes plugin manifests loaded by **electron**'s `plugin-library.ts`. The manifest schema is a third coupling axis between the two contexts.
## Rules for agents
- Add a row when a new subdomain gains its own `CONTEXT.md`.
- Update the public surface or relationships when they change.
- Keep this file scannable — one row per context, terse purpose strings.

222
agents-docs/ENGINEERING.md Normal file
View File

@@ -0,0 +1,222 @@
# Engineering Standards & Workflows
This document defines shared engineering practices for **MetoYou / Toju**.
---
## Root README.md policy
`README.md` exists to answer:
- what this repo is
- how to run it locally
- where to find canonical documentation
Agents should update `README.md` when dev commands change, ports or startup steps change, or links to docs move.
Agents should **not** describe feature behavior, list API endpoints, or include request/response schemas. Canonical documentation lives under `agents-docs/` and (for product-client bounded contexts) under `toju-app/src/app/domains/<name>/README.md`.
---
## Testing standards
This repo runs two test stacks. Choose by what you're verifying.
### Unit / component tests — Vitest
- **Framework:** Vitest 4.x
- **Where it runs:** the Angular product client (`toju-app/`) and any package that imports `@toju-app/*` modules; Electron has colocated `*.spec.ts` files that are wired through the same root Vitest config.
- **Test suffix:** `*.spec.ts`
- **Location:** colocated with source (`message-rules.ts``message-rules.spec.ts`)
- **Run all:** `npm run test` (from repo root — runs `cd toju-app && vitest run`)
- **Watch:** `cd toju-app && npx vitest`
- **Single file:** `cd toju-app && npx vitest run <relative-path>`
- **Setup file:** `toju-app/src/test-setup.ts`
The server package does not currently have a test runner script — there is one colocated spec (`server/src/websocket/handler-plugin.spec.ts`) but no `test` script in `server/package.json`. If you add server-side tests, wire a `test` script and update this section.
### End-to-end — Playwright
- **Framework:** Playwright 1.59
- **Location:** `e2e/tests/` organized by feature area (`voice/`, `chat/`, `screen-share/`, `settings/`, `auth/`)
- **Run:** `npm run test:e2e` (headless), `npm run test:e2e:ui`, `npm run test:e2e:debug`
- **Report:** `npm run test:e2e:report` (serves `test-results/html-report`)
- **Fixtures & page objects** live in `e2e/` alongside `tests/`
E2E tests exercise the real Electron app against the real signaling server. The `.agents/skills/playwright-e2e/SKILL.md` describes the convention this repo uses for E2E test design — read it before adding new tests.
### TDD discipline
Write the failing test first. Run it, watch it fail, then write the smallest code that makes it pass. This rule is non-negotiable (see `/AGENTS.md` § CRITICAL).
Integration / cross-package work that needs a real database can rely on Electron's TypeORM + sql.js setup (in-memory by default) — no Testcontainers required.
---
## TypeScript standards
- Strict mode is enabled across all packages
- Avoid `any` unless absolutely necessary; document why if used
- Prettier (`.prettierrc.json`: `printWidth: 150`, single quotes, no trailing commas) handles formatting of Angular HTML templates only — ESLint stylistic rules handle TypeScript/JavaScript formatting
- Angular CLI / `tsc -p tsconfig.electron.json` / `cd server && tsc` perform the actual type checks; there is no single repo-wide `typecheck` script
- The repository uses npm workspaces (`npm@10.9.2`); cross-package imports go through workspace package names, not relative `../../` paths
---
## Naming conventions
Files and folders are predominantly **kebab-case**, with a few well-established suffixes:
- Angular components: `chat-messages.component.ts`, `user-list.component.html`, `*.component.scss`
- Angular services: `link-metadata.service.ts`
- Angular directives: `chat-image-proxy-fallback.directive.ts`
- Domain rules (pure functions): `message.rules.ts`, `link-embed.rules.ts`
- Domain models: `chat-messages.model.ts`
- NgRx slices: `chat.actions.ts`, `chat.reducer.ts`, `chat.effects.ts`, `chat.selectors.ts`
- CQRS handlers (server and electron): `registerUser.ts`, `deleteServer.ts`, `upsertServer.ts`**camelCase** for handler files (mirrors the command/query name)
- Test files: `<name>.spec.ts` (Vitest), `<feature>.spec.ts` (Playwright)
- Migrations (TypeORM): `<timestamp>-<name>.ts` in `electron/migrations/` and `server/migrations/`
Types, interfaces, classes, and Angular component classes: `PascalCase`. Functions, variables, NgRx action props: `camelCase`. Constants: `SCREAMING_SNAKE_CASE`.
When in doubt, mimic the closest existing file in the same folder.
---
## Error handling
- Use typed errors. Never `throw 'string literal'`
- Never swallow errors silently — at minimum, log with enough context to find the call site
- Centralize cross-cutting error handling: Express error middleware on the server, NgRx effect `catchError` in the product client, and IPC error envelopes in Electron handlers
- Surfacing errors to the user is a UX concern — degrade gracefully (toast, retry button, offline banner) rather than crashing the renderer
---
## Database guidelines
Persistence uses **TypeORM 0.3** with **sql.js / SQLite** in both the Electron desktop shell and the signaling server.
- **Electron data source:** `electron/data-source.ts` — entities in `electron/entities/`, migrations in `electron/migrations/`
- **Server data source:** wired up under `server/src/db/` — entities in `server/src/entities/`, migrations in `server/src/migrations/`
- Always write a migration for schema changes. Generate with `npm run migration:generate` (Electron) or the equivalent inside `server/`
- Run pending migrations: `npm run migration:run` (Electron)
- Never edit a migration after it has shipped — write a new one
- Entity classes use TypeORM decorators; keep persistence concerns out of domain `*.rules.ts` files
- Schema changes are usually **hard to reverse** and **surprising without context** — see `agents-docs/AGENTS_ADRS.md` for when to also write an ADR
---
## Realtime, IPC, and plugins
These are the three cross-context contracts that change most often. Treat each as a public contract that requires `agents-docs/features/` updates when it changes:
- **WebSocket messages** between client and server — schemas live under `server/src/websocket/` and `toju-app/src/app/infrastructure/realtime/`
- **IPC channels** between Electron preload and renderer — surface defined in `electron/preload.ts` and the `api/` directory
- **Plugin manifests** consumed by `electron/plugin-library.ts` — the runtime contract that third-party plugins depend on
Behavioral changes to any of these qualify as a feature-doc update under the rule in `/AGENTS.md`.
---
## CI/CD
- CI runs on **Gitea Workflows** (a GitHub Actionscompatible runner) — workflow files in `.gitea/workflows/`:
- `release-draft.yml` — queues release builds on push to `main` / `master`
- `publish-draft-release.yml` — publishes draft releases
- `deploy-web-apps.yml` — deploys the marketing site and Docusaurus docs
- All checks must pass before merging a PR
- Workflow status is visible in the Gitea PR view; use the web UI or `tea` CLI to inspect runs
There is **no pre-commit hook** configured (no Husky, no pre-commit, no lefthook). Lint/build are enforced by CI, not by local hooks.
---
## Commit message conventions
Use **Conventional Commits** with no scope. The recent history is consistent on this:
```
feat: Update how messages load and sync, allow plugins to import messages
fix: Mobile style fixes and other small ui fixes
perf: server navigation
refactor: Remove hardcoded values
test: Ensure tests work after latest changes
```
Allowed prefixes (observed across the last 100 commits): `feat`, `fix`, `chore`, `docs`, `perf`, `refactor`, `test`. Subject is sentence case with no trailing period.
If your change resolves a Gitea issue, add `Fixes #<n>` (or `Relates to #<n>`) in the PR body — Gitea supports the same auto-close keywords as GitHub.
---
## Issue linking
- Issues live in the Gitea instance at `git.azaaxin.com/myxelium/Toju`
- Reference them in PR bodies with `Fixes #<n>` (auto-closes on merge) or `Relates to #<n>` (cross-reference only)
- Commits themselves do not need issue numbers — keep subjects clean and Conventional
---
## Commands reference
Run these from the repository root unless otherwise noted.
```bash
# --- setup ---
npm install # install root + workspaces
cd server && npm install # server has its own lockfile
cd website && npm install # only if working on the marketing site
cd docs-site && npm install # only if working on app/plugin docs
# --- common dev flows ---
npm run dev # full stack: server + Angular client + Electron (via dev.sh)
npm run start # Angular product client only (ng serve on :4200)
npm run electron:dev # Angular client + Electron, no signaling server
npm run server:dev # signaling server only (ts-node-dev)
# --- testing ---
npm run test # toju-app Vitest suite
npm run test:e2e # Playwright (headless)
npm run test:e2e:ui # Playwright UI mode
npm run test:e2e:debug # Playwright debug
npm run test:e2e:report # serve last Playwright HTML report
# --- type / build (also serves as typecheck) ---
npm run build # Angular product client → dist/client
npm run build:electron # tsc -p tsconfig.electron.json → dist/electron
npm run build:docs # Docusaurus → docs-site/build
cd server && npm run build # server tsc
npm run build:all # all of the above
# --- lint / format ---
npm run lint # eslint .
npm run lint:fix # format + sort:props + eslint --fix
npm run format # prettier on Angular HTML templates only
npm run format:check # prettier --check on HTML templates
# --- database migrations (Electron) ---
npm run migration:generate # autogenerate from entity diff
npm run migration:create # empty migration scaffold
npm run migration:run # apply pending
npm run migration:revert # roll back last
```
---
## Completion checklist
Before marking work complete:
- [ ] Tests written before implementation
- [ ] All tests passing (`npm run test`, plus `npm run test:e2e` if behavior is user-visible)
- [ ] `npm run lint` passes
- [ ] Affected package builds: `npm run build` / `npm run build:electron` / `cd server && npm run build`
- [ ] Naming conventions followed
- [ ] Errors handled
- [ ] Security considered (no secrets in code, no plaintext token logging, no IPC handler accepting arbitrary file paths)
- [ ] Feature docs updated if contract/schema/invariant changed (see `agents-docs/AGENTS_FEATURES.md`)
- [ ] `CONTEXT.md` updated if a domain term was resolved or introduced (see `agents-docs/AGENTS_CONTEXT.md`)
- [ ] ADR written if a hard-to-reverse decision was made (see `agents-docs/AGENTS_ADRS.md`)
- [ ] Lesson recorded in `agents-docs/LESSONS.md` if this session produced a correction, revert, or hidden constraint (see triggers in `agents-docs/AGENT_WORKFLOW.md`)
- [ ] PR opened with summary and linked issues
- [ ] Gitea Workflows checks passing

35
agents-docs/FEATURES.md Normal file
View File

@@ -0,0 +1,35 @@
# Feature Areas
This index represents the known feature areas in the system.
It must stay accurate as new features are introduced, renamed, merged, or removed.
---
## Feature list (alphabetical)
- [access-control](./features/access-control.md) — Roles, role assignments, channel permission overrides, memberships, invites, bans, and slowmode.
- [attachments](./features/attachments.md) — P2P chunked file-transfer protocol over the WebRTC chat data channel, storage decisions, auto-download rules.
- [authentication](./features/authentication.md) — User account REST surface, WebSocket `identify` handshake, heartbeat sweep, and Electron Local API tokens.
- [ipc-bridge](./features/ipc-bridge.md) — Electron preload `window.electronAPI` surface, IPC channels, and CQRS dispatch.
- [messaging](./features/messaging.md) — Server-channel chat, direct messages, inventory-sync protocol, delivery state machine.
- [plugin-system](./features/plugin-system.md) — Plugin manifest contract, renderer runtime, capability grants, and server `plugin-support` API.
- [presence](./features/presence.md) — Connection lifecycle, availability status, profile metadata propagation, voice membership, and game activity.
- [server-directory](./features/server-directory.md) — REST surface for server catalog, invites, join requests, and moderation.
- [voice-signaling](./features/voice-signaling.md) — WebRTC mesh signaling, RNNoise pipeline, and voice / direct-call / screen-share orchestration.
- [websocket-envelopes](./features/websocket-envelopes.md) — Wire-format contract for every realtime envelope between server and clients.
The product client already documents its bounded contexts at `toju-app/src/app/domains/<name>/README.md` (Access Control, Attachment, Authentication, Chat, Direct Call, Direct Message, Experimental Media, Game Activity, Notifications, Plugins, Profile Avatar, Screen Share, Server Directory, Theme, Voice Connection, Voice Session). Those domain READMEs cover internal product-client behavior.
`agents-docs/features/<slug>.md` is for **cross-context** contracts and feature areas that span more than one subdomain — WebSocket envelopes, IPC channels, plugin manifests, end-to-end flows that touch client + server + Electron together. Add an entry here the first time you write one.
---
## Rules for agents
- Introducing a new feature area requires:
- creating `agents-docs/features/<feature>.md` (use `agents-docs/features/feature-template.md`)
- adding it to this list (alphabetical)
- Renaming or merging features requires updating links and notes
- If the change is fully contained inside one product-client domain, prefer updating `toju-app/src/app/domains/<name>/README.md` over adding a top-level feature doc
- This file should remain concise and navigable

38
agents-docs/LESSONS.md Normal file
View File

@@ -0,0 +1,38 @@
# Agent Lessons
Durable rules for AI agents working on this project. Read this file at session start. Append to it when this session produces a correction worth remembering.
## How to use this file
**At session start:** scan the rules below. If any match the work you're about to do, apply them.
**During the session:** if the user corrects you, reverts your edit, or re-prompts with the same instruction — that is a signal to record a lesson before closing the task. See the trigger list in `agents-docs/AGENT_WORKFLOW.md`.
**Format of a lesson:** every entry uses the four-slot template below. Brevity matters — if you can't state the rule in one sentence, the lesson isn't sharp enough yet.
```markdown
### <short imperative title>
- **Trigger:** what you were about to do that turned out wrong (one line, concrete enough to pattern-match against)
- **Rule:** what to do instead (one sentence, imperative voice)
- **Why:** the consequence of getting it wrong — past incident, hidden constraint, user preference
- **Example:** one concrete instance, ideally a code or command snippet
```
**Keep lessons sharp.** Tag each rule with one or two tags in square brackets after the title (e.g. `[testing] [migrations]`) so future agents can grep for relevance. If a rule no longer applies, delete it — stale rules drown the real ones.
---
## Lessons
### Verify lint exits 0 before claiming done [verification]
- **Trigger:** about to report a task as complete after running tests but skipping ESLint.
- **Rule:** run `npm run lint` from the repo root and confirm exit code 0 before any "done" claim.
- **Why:** `npm run test` only runs the toju-app Vitest suite — it doesn't cover the server, Electron, or website packages. ESLint (flat config in `eslint.config.js`) is the universal check across every package; type-style violations slip through tests and break Gitea Workflows for the next agent.
- **Example:** `npm run lint && echo OK` — only claim done after seeing `OK`. For Electron type errors specifically, also confirm `npm run build:electron` succeeds (it invokes `tsc -p tsconfig.electron.json`).
<!--
Add new lessons above this comment, newest at the top.
Delete this example once the project has accumulated 2-3 real lessons.
-->

View File

@@ -0,0 +1,13 @@
# ADR-0001: Record Architectural Decisions
## Status
Accepted
## Context
We need a lightweight way to record architectural decisions so that future agents and engineers can understand *why* the system looks the way it does, not just *what* it does. Without ADRs, decisions live in PR descriptions, chat logs, or nowhere — and get re-litigated on every refactor.
## Decision
We use Architecture Decision Records (ADRs) in the Nygard short form. Each ADR lives at `agents-docs/adr/NNNN-slug.md` with a 4-digit zero-padded number, monotonically increasing. The minimum content is a title plus 13 sentences each for Context, Decision, and Rationale. Add `Status`, `Considered Options`, or `Consequences` only when they genuinely help.
## Rationale
Nygard short form is the lowest-friction format that still captures the *why*. Heavier templates (MADR, full IEEE 1471) routinely don't get written — the bar to start one is too high. ADRs are append-only: a superseded decision gets a new ADR with a `Supersedes ADR-NNNN` note while the old one stays in place. The 3-criteria gate (hard to reverse, surprising without context, genuine trade-offs) keeps the directory from filling with trivia. See `agents-docs/AGENTS_ADRS.md` for the full contract.

View File

@@ -0,0 +1,232 @@
# Access Control
> **Area:** access-control
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
Access control is the **permission engine** under every Toju server: roles and their assignments, per-channel permission overrides, server membership, invites, bans, and slowmode. It runs in two places at once. The signaling server enforces it as the source of truth on REST mutations and on the `join_server` WebSocket gate. The Angular product client maintains a parallel resolution path so the UI can disable controls the user is not allowed to use, but those client-side guards are advisory — the server is authoritative.
Most concepts here were introduced over two migrations: `1000000000001-ServerAccessControl.ts` added memberships, bans, and invites; `1000000000005-ServerRoleAccessControl.ts` introduced first-class roles, role assignments, and per-channel overrides. The client carries a back-compat path that re-derives the legacy `RoomPermissions` booleans from the new role state for older UI code paths.
## Responsibilities
- Define and persist roles, role assignments, and per-channel permission overrides.
- Decide whether an identity is allowed to enter a server (`authorizeWebSocketJoin`) and, if not yet a member, what step is required (open join, password, invite).
- Maintain memberships, bans, and invites with their lifecycles (invite expiry, ban expiry).
- Resolve the effective permission state for a (user, channel) pair via role-position precedence and channel overrides.
- Hydrate the room model on join so the client can apply the same resolution locally.
- Guard moderation actions against privilege escalation via role-position checks.
This area does **not** own:
- Server catalog, discoverability, search, or moderation reports → [server-directory](./server-directory.md).
- Authentication or identity binding → [authentication](./authentication.md).
- The wire format of `join_server`, `access_denied`, role/ban update envelopes → [websocket-envelopes](./websocket-envelopes.md).
- Online status, voice presence, game activity → [presence](./presence.md).
## Key concepts
- **Role** — named row in `server_role` with a `position` (higher number = higher precedence) and a `PermissionStatePayload` of per-key overrides (`allow | deny | inherit`).
- **System role** — bootstrap roles seeded by `1000000000005-ServerRoleAccessControl.ts`: `system-everyone`, `system-moderator`, `system-admin`. `system-everyone` applies to all members; the others are assignable.
- **RoleAssignment** — (server, user, role) row in `server_user_role`.
- **ChannelPermissionOverride** — (server, channel, role) row in `server_channel_permission` carrying allow / deny / inherit per permission key on top of the role's base state.
- **Membership** — (server, user) row in `server_membership` with `createdAt` and optional metadata.
- **Invite** — single-use or multi-use server invite in `server_invite`; default expiry **10 days** from creation.
- **Ban** — (server, user) entry in `server_ban` with optional `expiresAt` and `reason`. Persists across reconnects.
- **Slowmode** — per-channel send interval (`slowModeInterval` on the channel) — currently a client-rendered hint, not enforced server-side (see TODOs).
---
## Permission keys
Defined as a `PermissionStatePayload` in `server/src/cqrs/types.ts`. ~11 keys gate distinct capabilities:
- `manageServer` — edit server metadata (name, icon, settings).
- `manageChannels` — create / edit / delete channels.
- `manageRoles` — create / edit / delete roles (subject to role-position guard, see below).
- `moderateMember` — kick / ban / mute / change-nick on lower-positioned members.
- `inviteMember` — create invites.
- `viewChannel` — see a channel exists.
- `readMessages` — read message history in a channel.
- `writeMessages` — send messages.
- `manageMessages` — delete / pin others' messages.
- `connectVoice` — join a voice room.
- `speakVoice` — un-mute in a voice room.
Each key may be `allow`, `deny`, or `inherit` on a given role. The default state on `system-everyone` is permissive for "read / view / write" and restrictive for "manage / moderate"; see the migration for the seed values.
### Resolution algorithm
For a (user, channel) lookup:
1. Collect the user's role assignments for the server, plus the implicit `system-everyone`.
2. Order roles by `position` ascending — **lowest position resolved first**, highest position resolved last (last-writer-wins).
3. For each role, apply its base `PermissionStatePayload` to a running accumulator: `allow` and `deny` overwrite; `inherit` leaves the prior value intact.
4. Apply the channel's per-role overrides (`server_channel_permission`) on top, in the same position order.
5. The accumulator's final value per key is the effective permission.
Because there is no inherent `deny > allow` priority, a higher-positioned role's `allow` will *override* a lower-positioned role's `deny` on the same key. This is the intentional Discord-style "promote to override" model. Document any deviation from this when introducing a new key.
---
## Membership state machine
Entry into a server runs through `joinServerWithAccess` in `server/src/services/server-access.service.ts`:
1. Look up the server. If missing → reject.
2. Look up the user's membership. If active → fast-path success.
3. Look up an active ban for `(server, user)`. If present and not expired → reject with `banned`.
4. If the server is **public** (`isPublic = true`, no password, no invite required) → create membership, return success.
5. If the server is **password-protected** (`hasPassword = true`) → require the supplied password to hash-match. If mismatch → reject with `password_required` / `bad_password`.
6. If the server is **invite-only** → require a valid (`server_invite.code`) and non-expired invite. Consume the invite if single-use.
7. On success → insert a `server_membership` row, return ok.
`authorizeWebSocketJoin` is the lighter gate used on the `join_server` WebSocket envelope: it short-circuits to "allowed" if a membership already exists, otherwise reports the access mode needed. Unlike `joinServerWithAccess`, it does **not** consume invites or process passwords — those flow through dedicated REST routes on the server before the client retries the WebSocket join.
`handleJoinServer` (`server/src/websocket/handler.ts:155`) is the call site: on rejection the server sends `access_denied` with a reason (`banned`, `password_required`, `invite_required`, ...); on success it broadcasts presence (`user_joined`) per the rules documented in [presence](./presence.md).
---
## Moderation actions
Moderation is gated by two helpers in `server/src/services/server-permissions.service.ts`:
- `canManageServerUpdate(actorRoles, requested)` — maps the requested change (rename, icon change, permission edit, role create, invite, etc.) to the permission key it needs, then resolves the actor's effective state.
- `canModerateServerMember(actorHighestRole, targetHighestRole)` — privilege-escalation guard: the actor's highest-position role must be **strictly greater** than the target's highest-position role. Two moderators at the same position cannot ban each other.
Bans are written via `banServerUser`. The ban entity supports an optional `expiresAt` for time-limited bans and a `reason` string for moderator-facing UI.
Kick is implemented as "delete membership"; the connection is dropped via a subsequent WebSocket close on the next envelope.
---
## Client-side hydration
When the client joins a server, the server sends the room model with normalized access-control fields:
```
roles: ServerRolePayload[]
roleAssignments: RoleAssignmentPayload[]
channelPermissions: ChannelPermissionPayload[]
permissions: legacy RoomPermissions bools (back-compat)
slowModeInterval: number | undefined (per-channel)
```
`normalizeRoomAccessControl` in `toju-app/src/app/shared-kernel/room.models.ts` is the single normalization helper. It:
- Backfills the legacy `permissions` booleans from the role state so older NgRx selectors keep working.
- Sorts roles by `position` ascending.
- De-duplicates assignments by `(userId, roleId)`.
Client-side resolution mirrors the server algorithm and lives under `toju-app/src/app/domains/access-control/domain/rules/`. Selectors expose `canSendMessage(channelId)`, `canManageRole(roleId)`, etc., which UI components consume to disable controls.
`canManageRole` enforces the same privilege-escalation guard as the server: a user cannot edit a role at or above their own highest position.
---
## Invites
`server_invite` rows have:
- `code` — opaque token used in invite URLs.
- `serverId`, `createdById`.
- `expiresAt` — default 10 days from creation.
- `maxUses` / `uses` — single-use or multi-use semantics.
The REST surface for invite creation, lookup, and consumption is part of [server-directory](./server-directory.md). Invite consumption is **transactional**: a single-use invite is decremented before the membership row is created so a race cannot create two memberships off one invite.
---
## Bans
`server_ban` rows carry `userId`, `serverId`, optional `expiresAt`, optional `reason`, and `createdById`. Active bans are matched on `(serverId, userId)` with the expiry filter applied at read time. A ban broadcast envelope notifies connected peers so the client can drop the banned user from the local room model — TODO: confirm whether such an envelope exists today or whether clients only learn of bans on the next `server_users` snapshot.
---
## Slowmode
`slowModeInterval` is a per-channel hint expressed in seconds. The client renders the cooldown UI and is expected to gate the send button locally. The server does **not** enforce slowmode today — a non-cooperating client can ignore the interval. This is a known gap; see TODOs.
---
## Business rules and invariants
- **Server is authoritative.** Client-side `canX` selectors are advisory and exist to prevent UI confusion, not to gate security.
- **Last-writer-wins by position** — there is no inherent allow/deny priority; a higher-positioned role can override a lower-positioned role's deny.
- **Privilege escalation is blocked** by requiring strict position-greater on the moderator. Same-position moderation is rejected on both sides.
- **Invite consumption is atomic** for single-use invites.
- **Bans persist independently of memberships** — a banned user without a membership row is still banned.
- **`system-everyone` always applies** at position 0 (or whatever the migration seeds); it cannot be removed.
- **Channel overrides resolve last** after role base state.
---
## Technical implementation
### Server
- Services — `server/src/services/server-access.service.ts` (`authorizeWebSocketJoin`, `joinServerWithAccess`, `ensureServerMembership`, `banServerUser`, `leaveServerUser`); `server/src/services/server-permissions.service.ts` (`getServerRoles`, `getServerAssignments`, `resolveRolePermissionState`, `resolveHighestRole`, `canManageServerUpdate`, `canModerateServerMember`).
- Entities — `server/src/entities/ServerMembershipEntity.ts`, `ServerBanEntity.ts`, `ServerInviteEntity.ts`, `ServerRoleEntity.ts`, `ServerUserRoleEntity.ts`, `ServerChannelPermissionEntity.ts`.
- Migrations — `1000000000001-ServerAccessControl.ts`, `1000000000005-ServerRoleAccessControl.ts`.
- CQRS — payload types in `server/src/cqrs/types.ts` (`AccessRolePayload`, `PermissionStatePayload`, `RoleAssignmentPayload`, `ChannelPermissionPayload`); normalization in `server/src/cqrs/relations.ts` (`normalizeServerRoles`, `normalizeServerRoleAssignments`).
- REST — server / role / invite / ban routes are mounted under `server/src/routes/servers.ts` (catalog endpoints are documented in [server-directory](./server-directory.md)).
- WS — `server/src/websocket/handler.ts::handleJoinServer` (line 155), `access_denied` emission.
### Product client
- Domain — `toju-app/src/app/domains/access-control/`.
- Shared kernel — `toju-app/src/app/shared-kernel/access-control.models.ts`, `moderation.models.ts`, `room.models.ts` (`normalizeRoomAccessControl`).
- NgRx — access-control reducers / selectors under `toju-app/src/app/store/access-control/`.
---
## Testing
- TODO: no spec was located for `server-access.service.ts` or `server-permissions.service.ts`.
- TODO: no spec was located for the `authorizeWebSocketJoin` rejection paths.
- Client-side rule specs likely exist under `toju-app/src/app/domains/access-control/domain/rules/*.spec.ts` — confirm and list when filling in.
- TODO: E2E coverage for invite consumption, ban enforcement, and role-position escalation.
---
## Security considerations
- **Slowmode is not server-enforced.** A modified client can spam. Treat slowmode as an anti-accident measure, not abuse mitigation.
- **No server-side channel-permission enforcement on message send** — only role-state is checked at the join gate. TODO: verify whether per-channel overrides are applied on the message-send path.
- **No audit log** of moderation actions. TODO.
- **Password-protected servers** rely on the same SHA-256 hashing used for AuthUser passwords — see Security in [authentication](./authentication.md); the same caveats apply.
- **Position-based escalation guard** works only if positions are well-ordered. Position assignment is on `manageRoles`-bearing users; misconfiguration can produce moderators who can demote each other arbitrarily.
- **Bans are not always broadcast in real time** — clients may discover an ongoing ban only on the next `server_users` snapshot. TODO: confirm the live ban envelope.
---
## Performance considerations
- Role and assignment lookups are point queries on `server_role` / `server_user_role` indexed by `serverId`.
- Per-channel override resolution is O(roles × channels) at hydration time; happens once on `join_server` and is cached client-side.
- `authorizeWebSocketJoin` is O(1) for existing members (single membership lookup), O(1)O(invites) for invite consumption.
---
## Known issues and limitations
- **No server-side slowmode enforcement.**
- **No dedicated ban-broadcast envelope** (or unconfirmed).
- **No audit log** for moderation actions.
- **Channel-permission overrides may not be applied on the message-send path** server-side — TODO.
- **Unsalted SHA-256** for password-protected server passwords — same gap as user passwords.
---
## Related features
- **[server-directory](./server-directory.md)** — owns server catalog, discoverability, and the REST surface for invites/bans/roles.
- **[authentication](./authentication.md)** — provides the `oderId` identity that access-control authorizes.
- **[presence](./presence.md)** — `user_joined` / `user_left` are emitted only after `authorizeWebSocketJoin` succeeds.
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of `join_server`, `access_denied`, role/ban update envelopes.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

View File

@@ -0,0 +1,196 @@
# Attachments
> **Area:** attachments
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
Attachments are pure peer-to-peer in Toju. The signaling server never sees a file byte. A sender announces an attachment on the WebRTC chat data channel; a receiver requests it; the sender streams base64-encoded 64 KiB chunks back; the receiver reassembles and (on Electron) writes the result to disk under a per-conversation folder. If the original sender goes offline mid-transfer, the receiver can re-request from another peer that previously announced the same attachment. There is no inventory protocol, no integrity signature, and no server-side fallback — attachments live entirely on the participants' machines.
This area is the closest sibling of [voice-signaling](./voice-signaling.md): both are P2P protocols that ride the same RTCPeerConnection. The chat events that drive attachments are members of the `ChatEvent` union; they share the data channel with chat messages but are conceptually distinct.
## Responsibilities
- Define the file-transfer envelope set (announce / request / chunk / cancel / not-found) and its sequencing rules.
- Maintain per-transfer state on both sides — chunk index, in-flight chunk, retry/failover bookkeeping.
- Decide whether to auto-download (size + media-type heuristic).
- Decide where to persist (Electron disk vs browser memory).
- Estimate transfer speed via EWMA so the UI can render a progress bar that doesn't jitter.
- Pick a failover peer when the current sender disappears.
This area does **not** own:
- The chat message that references the attachment → [messaging](./messaging.md).
- The peer connection or data channel itself → [voice-signaling](./voice-signaling.md).
- The IPC channels used to read / write the file on Electron → [ipc-bridge](./ipc-bridge.md).
- Permission to upload — there is no formal upload gate today; access-control's `writeMessages` is the proxy. See [access-control](./access-control.md).
## Key concepts
- **Attachment** — a file announced and referenced by a chat message. Persisted independently of the message body.
- **Transfer** — the per-receiver state for a single in-flight attachment.
- **Bucket** — storage subfolder: `image | video | audio | files`. Determined by MIME type.
- **Tried-peer set** — the set of peers a receiver has already attempted for a given `${messageId}:${fileId}`; used to drive failover without re-trying the same peer in a loop.
- **`uploaderPeerId`** — the original announcer; the receiver prefers it over the tried-peer set when (re-)issuing a `file-request`.
---
## Protocol
The five events live in the `ChatEvent` union (`toju-app/src/app/shared-kernel/chat-events.ts`) and ride the WebRTC `chat` data channel. They do **not** flow through the WebSocket signaling server.
- `file-announce` — sender announces an attachment alongside a chat message. Carries `messageId`, `fileId`, `name`, `size`, `mimeType`, optional preview metadata.
- `file-request` — receiver requests the attachment from a specific peer.
- `file-chunk` — sender streams `index`, base64-encoded chunk payload, and `total` chunk count.
- `file-cancel` — either side aborts the in-flight transfer.
- `file-not-found` — sender responds when asked for an unknown `fileId`.
### Constants
Defined in the attachment domain (`toju-app/src/app/domains/attachment/`):
- `P2P_BASE64_CHUNK_SIZE_BYTES = 64 * 1024` — re-exported as `FILE_CHUNK_SIZE_BYTES`. Shared with the avatar P2P sync path.
- `MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024` — files at or under 10 MiB are auto-downloaded on receipt.
- `MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES = 50 * 1024 * 1024` — browser-mode cap on inlined media.
- **EWMA weights** — previous-weight `0.7`, current-weight `0.3` for transfer-rate smoothing.
- **Data-channel water marks** — `highWaterMark = 4 MiB`, `lowWaterMark = 1 MiB` for backpressure pacing.
### Flow
1. Sender computes attachment metadata and emits `file-announce` referencing the chat message.
2. Receiver opens a transfer state. Auto-download triggers if `size ≤ MAX_AUTO_SAVE_SIZE_BYTES` and the MIME type is in the allow-list for the bucket. Larger files require an explicit user click.
3. Receiver sends `file-request` to `uploaderPeerId`.
4. Sender streams `file-chunk` events sequentially. **Exactly one chunk is in flight per receiver at a time** — the sender awaits the per-chunk write/ack before queueing the next one. On Electron the receiver writes each chunk to disk; the protocol requires `index === receivedCount` for the next chunk or the transfer aborts.
5. Receiver reassembles. On Electron, the file lands at:
- `{appData}/server/{room}/{bucket}/{id}{.ext}` for server-channel attachments.
- `{appData}/direct-messages/{conv}/{bucket}/{id}{.ext}` for DM attachments.
- Browser mode keeps the file as a Blob in memory — lost on reload.
6. Either side may `file-cancel`; the sender returns `file-not-found` if the requested `fileId` is unknown.
### Failover
- Receiver-driven. No inventory protocol.
- Sequential — tries one peer at a time.
- The tried-peer set is keyed by `${messageId}:${fileId}`.
- `uploaderPeerId` is always preferred when reachable; the tried-peer set ensures it isn't re-attempted in a busy loop after a failure.
- If every available peer is in the tried set, the transfer ends in a not-found state and surfaces a UI prompt.
---
## Storage
### Electron
- `AttachmentEntity` — TypeORM row in the per-user local database. Carries `id`, `messageId`, `roomId` / `conversationId`, `name`, `size`, `mimeType`, `bucket`, `relativePath`, `createdAt`.
- CQRS commands — `save-attachment`, `delete-attachments-for-message`.
- CQRS queries — `get-all-attachments`, `get-attachments-for-message`.
- Filesystem IPC — `read-file-chunk`, `get-file-size`, `write-file`, `append-file`, `get-file-url`, `file-exists`, `delete-file`, `ensure-dir`, `get-app-data-path`.
The renderer never touches Node.js filesystem APIs directly; every read/write is brokered through [ipc-bridge](./ipc-bridge.md).
### Browser
When the desktop shell is not present, attachments stay in-memory as Blob URLs. Reloading the renderer loses them; this is documented behavior, not a bug.
---
## Auto-download heuristic
- Any file with `size ≤ 10 MiB` and a media MIME type (`image/*`, `video/*`, `audio/*`) is auto-downloaded on receipt so the chat UI can render it inline.
- Files above the cap or in the `files` bucket require an explicit click. The chat UI shows a "Download" affordance with the file size.
---
## Speed estimation (EWMA)
Transfer rate is exposed to the UI via an exponentially-weighted moving average:
```
rate_t = 0.7 · rate_{t-1} + 0.3 · instantaneous_t
```
Smooth enough for a stable progress display; responsive enough to surface a stalled transfer within a few seconds.
---
## Business rules and invariants
- Attachments are **pure P2P** — the signaling server never sees an attachment byte.
- **One chunk in flight per sender → receiver** (`await` per chunk). No parallelism within a single transfer.
- **Sequential chunk indices on Electron disk receive** — `index === receivedCount` is enforced; mismatches abort.
- **`PeerDeliveryService` is not on the attachment path.** Attachments use `RealtimeSessionFacade.broadcastMessage` / `sendToPeer` / `sendToPeerBuffered` directly.
- **Browser mode loses everything on reload** — no IndexedDB persistence today for attachments.
- **No integrity / signature check** on chunks; no encryption at rest beyond OS file permissions.
- **Failover is receiver-driven** and tried-peer-set deduplicated.
---
## Technical implementation
### Product client
- Domain — `toju-app/src/app/domains/attachment/`: manager, transfer state, persistence selection.
- Contracts — `toju-app/src/app/shared-kernel/attachment-contracts.ts`, `chat-events.ts` (the five envelope types).
- Realtime send paths — `RealtimeSessionFacade.broadcastMessage` / `sendToPeer` / `sendToPeerBuffered` in the realtime infrastructure tree.
### Electron
- Entity — `AttachmentEntity` in `electron/entities/`.
- CQRS handlers — under `electron/src/cqrs/` (or equivalent) for `save-attachment`, `delete-attachments-for-message`, `get-all-attachments`, `get-attachments-for-message`.
- Filesystem IPC handlers — `electron/ipc/`: `read-file-chunk`, `get-file-size`, `write-file`, `append-file`, `get-file-url`, `file-exists`, `delete-file`, `ensure-dir`, `get-app-data-path`.
### Key types
- `AttachmentEntity` — local persistence row.
- `FileChunkEvent`, `FileAnnounceEvent`, `FileRequestEvent`, `FileCancelEvent`, `FileNotFoundEvent` — member shapes of the `ChatEvent` union.
---
## Testing
- TODO: no dedicated `*.spec.ts` files under `toju-app/src/app/domains/attachment/` at time of writing.
- E2E: `e2e/tests/chat/chat-message-features.spec.ts` includes `test('syncs image and file attachments between users', ...)` which covers happy-path attachment sync.
- TODO: no E2E coverage for multi-peer failover.
- TODO: no E2E coverage for `file-cancel`.
---
## Security considerations
- **No integrity signature.** A malicious sender can corrupt a chunk; the receiver assembles whatever arrives.
- **No encryption at rest** beyond OS-level file permissions on the per-user app-data folder.
- **No MIME-type sanitation.** The receiver trusts the announced `mimeType` for bucket routing; a misleading MIME does not change the on-disk contents but does affect inline rendering. Browser-side renderers must defend against this.
- **No size cap server-side.** Caps are receiver-side and advisory: `MAX_AUTO_SAVE_SIZE_BYTES` for auto-download, `MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES` for in-memory media. A sender can announce arbitrarily large files; the receiver simply refuses them.
- **Receivers expose disk write paths** indirectly: a misbehaving peer cannot escape `{appData}/server/...` or `{appData}/direct-messages/...` because the relative path is computed by the receiver, not transmitted by the sender — but this property must be preserved in any future protocol change.
---
## Performance considerations
- **Base64 overhead.** ~33 % inflation on the wire; a 64 KiB binary chunk is ~86 KiB on the wire.
- **Single chunk in flight** per (sender, receiver) — caps single-receiver throughput at one round-trip per chunk.
- **Data-channel water marks** (4 MiB high, 1 MiB low) provide back-pressure pacing without tuning per-NIC.
- **No FEC, no parallel chunks, no resumption across browser reloads.**
---
## Known issues and limitations
- **No dedicated unit specs** for the attachment domain.
- **No resume across browser reloads** (Electron writes to disk and survives; browser does not).
- **No checksum / signed integrity** on chunks.
- **No encryption at rest** beyond OS file permissions.
- **No server-side fallback** if every peer is offline — attachments are unreachable until at least one peer with the file returns.
---
## Related features
- **[messaging](./messaging.md)** — chat messages reference attachments; attachments are persisted separately from message bodies.
- **[voice-signaling](./voice-signaling.md)** — establishes the data channel that attachments ride on.
- **[ipc-bridge](./ipc-bridge.md)** — exposes the filesystem and CQRS APIs the Electron persistence path uses.
- **[websocket-envelopes](./websocket-envelopes.md)** — for context only; attachments do not flow through the signaling server.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

View File

@@ -0,0 +1,194 @@
# Authentication
> **Area:** authentication
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
User identity in Toju is split into two surfaces. A small **HTTP credential surface** on the signaling server (`/api/users/register`, `/api/users/login`) registers and verifies user accounts persisted in TypeORM. A separate **WebSocket `identify` handshake** binds a *self-asserted* identity (`oderId` + display name + optional description) to a live WebSocket connection so the server can route envelopes. There is no server-issued session token: the client re-asserts identity on every reconnect, and other peers trust the claim as far as the signaling fabric does — i.e. only to the extent that subsequent authorization checks (see [access-control](./access-control.md)) accept it.
The Electron desktop shell adds a third surface — the **Local API token store** in `electron/api/auth-store.ts` — which issues short-lived bearer tokens for the in-process HTTP server that hosts the docs site and OpenAPI bundle. That surface is internal to the desktop process and is documented here only because it shares the "authentication" name.
## Responsibilities
- Register and authenticate user accounts against the signaling server's `users` table.
- Bind a connection-scoped identity to a WebSocket connection via the `identify` envelope, including profile metadata propagation.
- Detect dead WebSocket connections via ping/pong sweeps and reap stale `ConnectedUser` rows.
- Mint and validate short-lived bearer tokens for the Electron Local API server.
This area does **not** own:
- Permissions, roles, bans, or membership state → [access-control](./access-control.md).
- Online / away / busy status, voice presence, game activity → [presence](./presence.md).
- The shape of WebSocket envelopes carrying identity claims → [websocket-envelopes](./websocket-envelopes.md).
- Profile avatar bytes or per-user assets → product-client `profile-avatar` domain.
## Key concepts
- **AuthUser** — server-persisted account: `id` (uuid), `username` (unique), `passwordHash`, `displayName`, `createdAt`. Defined in `server/src/entities/AuthUserEntity.ts`.
- **oderId** — client-asserted user identifier sent on `identify`. Used as the broadcast routing key. The server **trusts** it; there is no cryptographic binding between an AuthUser row and the `oderId` claimed over WebSocket.
- **Identify handshake** — first message a client sends on a WebSocket. Carries `oderId`, `displayName`, optional `description`, optional `profileUpdatedAt`, optional `connectionScope`.
- **Connection scope** — opaque string (typically the signal URL the client connected through). Used together with `oderId` to disambiguate multiple sockets per identity so stale-connection eviction does not loop across signal URLs.
- **Local API token** — bearer token issued by the Electron desktop process, 24 h TTL, kept in-memory and pruned on access.
---
## HTTP credential surface
Mounted at `/api/users` (see `server/src/routes/index.ts:20`). All payloads are JSON.
### `POST /api/users/register`
**Request:**
```json
{
"username": "string (required, unique)",
"password": "string (required)",
"displayName": "string (optional, defaults to username)"
}
```
**Response (201):**
```json
{ "id": "uuid", "username": "string", "displayName": "string" }
```
**Errors:**
- **400** `Missing username/password` — either field absent.
- **409** `Username taken` — username already exists.
### `POST /api/users/login`
**Request:** `{ "username": "string", "password": "string" }`
**Response (200):** `{ "id": "uuid", "username": "string", "displayName": "string" }`
**Errors:**
- **401** `Invalid credentials` — no row matches, or stored hash differs.
No session cookie, JWT, or bearer token is issued — the response is purely informational. The client is expected to remember the username and re-present it via the WebSocket `identify` handshake on every reconnect.
---
## WebSocket `identify` handshake
`handleIdentify` (`server/src/websocket/handler.ts:112`) processes the first envelope a client sends. It:
1. Reads `oderId` (falls back to `connectionId` when absent).
2. Reads `connectionScope` (opaque routing key).
3. Reads / normalizes `displayName`, `description`, `profileUpdatedAt`.
4. Mutates the `ConnectedUser` row in `connectedUsers`.
5. If any of `displayName` / `description` / `profileUpdatedAt` changed, rebroadcasts `user_joined` to every server the user is currently in.
`identify` itself is unauthenticated — the server does not consult `AuthUserEntity` here. Authorization happens later, at `join_server` time, via `authorizeWebSocketJoin` (documented in [access-control](./access-control.md)).
`identify` is the canonical channel for **profile updates**. Renaming yourself or updating your description means resending `identify`; the rebroadcast pushes the new profile to peers without disconnect/reconnect.
---
## Heartbeat and dead-connection sweep
Defined in `server/src/websocket/index.ts:19``75`:
- `PING_INTERVAL_MS = 30_000` — server pings every connection every 30 s.
- `PONG_TIMEOUT_MS = 45_000` — a connection whose `lastPong` is older than 45 s is closed and removed from `connectedUsers`.
`lastPong` is bumped on any inbound frame (not just pong), so an active client cannot be reaped while sending traffic. Eviction triggers `handleLeaveServer` for every server the connection had joined, which in turn emits `user_left` if no other connection of the same `oderId` still holds the server.
---
## Electron Local API token store
`electron/api/auth-store.ts` mints opaque bearer tokens used by the Local API server (`electron/api/router.ts`) to gate calls to `/api/auth/login` and other authenticated routes. Tokens have a 24 h TTL and are kept in-memory only — they do not persist across desktop restarts. Pruning happens lazily on lookup.
This surface is **not** the same identity as the signaling server's AuthUser. It is a desktop-local affordance for the in-process HTTP server that serves docs and plugin APIs; the renderer never sees these tokens.
---
## Business rules and invariants
- Usernames are **unique** at the database level (`@Column('text', { unique: true })` on `AuthUserEntity.username`) AND pre-checked in the route handler. Comparison is **case-sensitive**.
- Passwords are hashed with **unsalted SHA-256** (`crypto.createHash('sha256')`, `routes/users.ts:8`). There is no salting, no peppering, no iteration count, no Argon2/bcrypt. This is a known weakness — see Security below.
- The signaling server never issues a session token. Identity is re-asserted on every reconnect via `identify`.
- The `identify` claim is **not verified** against `AuthUserEntity`. Two clients can claim the same `oderId`; only `(oderId, connectionScope)` is used to deduplicate eviction.
- `user_joined` is only re-broadcast on `identify` when at least one of `displayName` / `description` / `profileUpdatedAt` actually changed — duplicate identifies are silent.
- Dead-connection sweep runs continuously. A client that goes silent for ≥ 45 s is treated as disconnected.
---
## Technical implementation
### Server (signaling)
- HTTP routes: `server/src/routes/users.ts`, mounted at `/api/users` in `server/src/routes/index.ts`.
- CQRS handlers: `server/src/cqrs/commands/handlers/registerUser.ts`, `server/src/cqrs/queries/handlers/getUserByUsername.ts`, `server/src/cqrs/queries/handlers/getUserById.ts`.
- Entity: `server/src/entities/AuthUserEntity.ts` (`users` table).
- Migration: `server/src/migrations/1000000000000-InitialSchema.ts`.
- WebSocket handshake: `server/src/websocket/handler.ts::handleIdentify` (line 112).
- `ConnectedUser` shape: `server/src/websocket/types.ts`.
- Heartbeat sweep: `server/src/websocket/index.ts`.
### Product client
- Domain: `toju-app/src/app/domains/authentication/`.
- Service: `authentication.service.ts` (login / register HTTP calls).
- Components: `login.component.ts`, `register.component.ts`.
- Model: `authentication.model.ts`.
### Electron
- Local API token store: `electron/api/auth-store.ts`.
- Local API router: `electron/api/router.ts` (`/api/auth/login` endpoint).
### Key types
- `AuthUserEntity` — server account row.
- `ConnectedUser` — live WebSocket connection state, including `oderId`, `connectionScope`, `lastPong`.
---
## Testing
- E2E: `e2e/tests/auth/user-session-data-isolation.spec.ts` — verifies session-level data isolation between users.
- TODO: no unit specs were located for `server/src/routes/users.ts`, `handleRegisterUser`, `getUserByUsername`/`getUserById`, `handleIdentify`, the Electron `/api/auth/login` proxy, or the `toju-app` authentication services.
- TODO: no happy-path login/register E2E exists today.
---
## Security considerations
- **Password hashing is unsalted SHA-256.** Vulnerable to rainbow-table and parallel GPU attacks. Replacing with Argon2id or bcrypt is the obvious upgrade path and is currently a TODO.
- **No rate limiting on `/login`.** A `users` table with weak hashes is exposed to credential stuffing and online brute-force.
- **`identify` is unauthenticated.** Any WebSocket can claim any `oderId`. The real authorization gate is `authorizeWebSocketJoin` on `join_server`, which checks membership / invite / password against the access-control tables — until that gate is crossed, an unverified `oderId` cannot do anything meaningful beyond joining the public lobby.
- **No reuse-prevention on `displayName`.** Two distinct accounts may carry the same display name. UI must therefore disambiguate by `oderId` where identity actually matters.
- **Local API tokens** never leave the desktop process and have a 24 h TTL — they are not a credential primitive for the signaling server.
---
## Performance considerations
- `/register` and `/login` are O(1) lookups against a `UNIQUE` index on `username`. No caching layer.
- `identify` is O(serversJoinedByThisConnection) on profile change because of the rebroadcast loop; profile updates are rare so this is negligible.
- Dead-connection sweep is O(connections) per `PING_INTERVAL_MS`; trivially scalable for a single-process signaling server.
---
## Known issues and limitations
- **Unsalted SHA-256 password hashing.** Highest-priority hardening target.
- **No password reset, email confirmation, MFA, or account recovery.**
- **No audit log** of register/login events.
- **No binding between `AuthUserEntity.id` and the claimed `oderId`.** A future hardening pass should require the client to prove possession of an `AuthUser` credential before the server accepts an `identify` payload that names that user — likely via a signed challenge.
- **No spec coverage** for the HTTP credential surface or the identify handshake.
---
## Related features
- **[access-control](./access-control.md)** — consumes the `oderId` claimed via `identify` to authorize server joins, role lookups, and moderation actions.
- **[presence](./presence.md)** — `identify` is the canonical channel for profile-metadata updates that presence broadcasts forward.
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of `identify`, `user_joined`, and `access_denied`.
- **[ipc-bridge](./ipc-bridge.md)** — the Electron Local API token store lives behind the same IPC boundary as other privileged operations.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

View File

@@ -0,0 +1,183 @@
# [Feature Name]
> **Area:** [area-name]
> **Status:** Active | In Progress | Deprecated
> **Last updated:** YYYY-MM-DD
## Overview
One paragraph describing what this feature does and why it exists.
## Responsibilities
- What this feature is responsible for
- Its boundaries — what it does NOT own
## Key concepts
- **ConceptA**: short definition
- **ConceptB**: short definition
---
## API Endpoint
### Endpoint Details
- **Method**: [GET | POST | PUT | PATCH | DELETE]
- **Path**: `/api/v1/[feature-path]`
- **Authentication**: [Required | Optional | None]
- **Rate Limiting**: [Yes — describe | No]
### Request Schema
```json
{
"field": "type — description"
}
```
**Required fields:**
- `field` (type, constraints): description
**Optional fields:**
- `field` (type): description. Defaults to "X" if not provided.
### Response Schema
```json
{
"field": "type — description"
}
```
### Error Responses
- **400 Bad Request**: [specific causes]
- **401 Unauthorized**: missing or invalid authentication
- **404 Not Found**: [when this applies]
- **500 Internal Server Error**: [specific causes]
---
## Business Logic
### Core Functionality
1. **Step 1**: description
2. **Step 2**: description
3. **Step 3**: description
### Business Rules
- Rule 1
- Rule 2
### Data Flow
```
Input → Validation → [Processing Steps] → Response
```
### Dependencies
- **Service/Library**: what it's used for
- **External API**: what it's used for
- **Database**: what tables/collections are involved
---
## Technical Implementation
### Service Layer
- **Location**: `path/to/service`
- **Key methods**: `methodName()` — description
### Controller / Handler
- **Location**: `path/to/handler`
- **Responsibilities**: request validation, service invocation, response formatting
### Repository / Data Access
- **Location**: `path/to/repository`
- **Tables/Collections**: list the relevant database objects
- **Migrations**: reference the migration that created/modified the schema
### Key Types
- `TypeName`: description of what it represents
---
## Configuration
### Environment Variables
- `VAR_NAME`: description (required | optional, default: X)
### Feature Flags
- [List any feature flags, or "None"]
---
## Testing
### Unit Tests
- **Location**: `path/to/tests`
- **Key scenarios**: list the most important test cases
### Integration Tests
- **Location**: `path/to/integration/tests`
- **Setup**: describe any required infrastructure (database, external services, etc.)
- **Mocking**: what external services are mocked and how
---
## Error Handling & Edge Cases
### Common Errors
- **Error scenario**: how it's handled
### Edge Cases
- **Edge case**: expected behavior
---
## Security Considerations
- Authentication requirements
- Authorization / access control
- Input validation and sanitization
- Data privacy considerations
---
## Performance Considerations
- Expected response times
- Known bottlenecks
- Caching strategy (if any)
---
## Known Issues and Limitations
1. **Limitation**: description
---
## Related Features
- **[Related Feature]**: brief description of relationship
## Changelog
| Date | Change |
|------|--------|
| YYYY-MM-DD | Initial documentation |

View File

@@ -0,0 +1,178 @@
# Electron IPC Bridge
> **Area:** ipc-bridge
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
The Electron IPC bridge is the only path through which the Angular renderer can reach the desktop runtime — the filesystem, the local SQLite database, OS APIs, the update flow, plugin loading, and the in-process Local API server. The renderer cannot import `electron`, `node:fs`, TypeORM, or any other privileged module directly; every privileged operation crosses the preload `contextBridge` boundary as a typed IPC call. This area documents the surface itself: how it is registered, how it is consumed, and what guarantees do (or do not) hold.
## Responsibilities
- Expose a frozen, allow-listed set of methods on the renderer's global window object via the preload bridge.
- Register one `ipcMain` handler per exposed method, grouped by concern (`system`, `window-controls`, `cqrs`).
- Provide a CQRS abstraction over the local database (commands + queries dispatched through two generic channels).
- Translate main-process operations into renderer-safe values (file paths → URLs, native errors → structured responses where appropriate).
This area does **not** own:
- The schema or business logic behind any specific command/query (those live in `electron/cqrs/handlers/` and the affected product-client domains).
- The plugin manifest contract (see [plugin-system](./plugin-system.md)) — only the IPC methods that surface it.
- WebSocket signaling (see [websocket-envelopes](./websocket-envelopes.md)) — that bypasses Electron entirely.
## Key concepts
- **Preload bridge** — `electron/preload.ts`. The sole place `contextBridge.exposeInMainWorld` runs. Adding a method here requires a matching `ipcMain.handle` / `ipcMain.on` on the main side.
- **Window surface** — exposed as `window.electronAPI` on the renderer. (Note: `electron/CONTEXT.md` refers to this as `window.api.*` — the documented intent. The literal global today is `electronAPI`. TODO: pick one and align.)
- **CQRS channel** — two reserved channels `cqrs:command` and `cqrs:query` route every typed `Command`/`Query` through a single dispatch step, instead of one channel per operation.
- **Handler setup function** — registered once at app boot from `electron/ipc/index.ts`: `setupCqrsHandlers`, `setupSystemHandlers`, `setupWindowControlHandlers`.
- **Renderer bridge service** — `ElectronBridgeService` in `toju-app/src/app/core/platform/electron/electron-bridge.service.ts` is the Angular-side wrapper; domain services inject it rather than reaching for `window.electronAPI` directly.
---
## Surface catalogue
Defined in `electron/preload.ts`. Approximately 50 methods, grouped below by concern. For exact signatures see the file.
### Window controls (fire-and-forget)
- `minimizeWindow`, `maximizeWindow`, `closeWindow` — channels `window-minimize`, `window-maximize`, `window-close`. Implementation: `electron/ipc/window-controls.ts`. Uses `ipcMain.on` (no return value).
### Screen share & media
- `getSources` — DesktopCapturer source enumeration.
- Linux audio routing for screen-share monitor capture: `prepareLinuxScreenShareAudioRouting`, `activateLinuxScreenShareAudioRouting`, `deactivateLinuxScreenShareAudioRouting`, `startLinuxScreenShareMonitorCapture`, `stopLinuxScreenShareMonitorCapture`.
- Event listeners: `onLinuxScreenShareMonitorAudioChunk`, `onLinuxScreenShareMonitorAudioEnded`.
### Process & game detection
- `getRunningProcessNames` (via `electron/process-list.ts`).
- `getActiveGameCandidate` (via `electron/game-detection/`).
- `getIgnoredGameProcesses`, `setIgnoredGameProcesses`.
### File system
- `readFile`, `readFileChunk`, `getFileSize`, `writeFile`, `appendFile`, `deleteFile`, `fileExists`, `getFileUrl`, `ensureDir`, `saveFileAs`, `saveExistingFileAs`, `openFilePath`, `readClipboardFiles`.
- `getFileUrl` is the canonical way for the renderer to display a local file via `file://` — direct path access is forbidden.
### Theme & plugins (filesystem-backed)
- `getSavedThemesPath`, `listSavedThemes`, `readSavedTheme`, `writeSavedTheme`, `deleteSavedTheme`.
- `getLocalPluginsPath`, `listLocalPluginManifests`. See [plugin-system](./plugin-system.md) for the manifest contract.
### Settings & notifications
- `getDesktopSettings`, `setDesktopSettings`.
- `showDesktopNotification`, `requestWindowAttention`, `clearWindowAttention`, `onWindowStateChanged`.
### Auto-update
- `getAutoUpdateState`, `getAutoUpdateServerHealth`, `configureAutoUpdateContext`, `checkForAppUpdates`, `restartToApplyUpdate`, `onAutoUpdateStateChanged`.
### Local API & docs
- `getLocalApiStatus`, `openLocalApiDocs`, `openDocusaurusDocs`. The Local API server hosts the prebuilt Docusaurus bundle inside the desktop app — see `electron/api/local-api-server.ts`.
### App & deep links
- `relaunchApp`, `consumePendingDeepLink`, `onDeepLinkReceived`, `getAppDataPath`, `openCurrentDataFolder`.
### Data management
- `exportUserData`, `importUserData`, `eraseUserData`. Backed by `electron/data-archive.ts`.
### Clipboard & context menu
- `copyImageToClipboard`, `onContextMenu`, `contextMenuCommand`.
### Idle state
- `getIdleState`, `onIdleStateChanged`. Backed by `electron/idle/`.
### CQRS (typed database access)
- `command<T>(command: Command) => Promise<T>` → channel `cqrs:command`.
- `query<T>(query: Query) => Promise<T>` → channel `cqrs:query`.
- Command and query union types live in `electron/cqrs/types.ts`. Handlers are built dynamically per `DataSource` via `buildCommandHandlers(dataSource)` and `buildQueryHandlers(dataSource)` in `electron/ipc/cqrs.ts`.
- Current commands: `SaveMessage`, `DeleteMessage`, `UpdateMessage`, `ClearRoomMessages`, `SaveReaction`, `RemoveReaction`, `SaveUser`, `SetCurrentUserId`, `UpdateUser`, `SaveRoom`, `DeleteRoom`, `UpdateRoom`, `SaveBan`, `RemoveBan`, `SaveAttachment`, `DeleteAttachmentsForMessage`, `SavePluginData`, `DeletePluginData`, `SaveMeta`, `ClearAllData`.
- Current queries: `GetMessages`, `GetMessagesSince`, `GetMessageById`, `GetReactionsForMessage`, `GetUser`, `GetCurrentUser`, `GetCurrentUserId`, `GetUsersByRoom`, `GetRoom`, `GetAllRooms`, `GetBansForRoom`, `IsUserBanned`, `GetAttachmentsForMessage`, `GetAllAttachments`, `GetPluginData`, `GetMeta`.
- Unknown `type` raises `Error("No command/query handler for type: ${type}")`.
---
## Renderer consumption
- **`ElectronBridgeService`** (`toju-app/src/app/core/platform/electron/electron-bridge.service.ts`) — provides `getApi(): ElectronApi | null` and `requireApi(): ElectronApi`. Domain services inject the bridge service, never `window` directly. This also makes the bridge mockable for spec runs and the website preview (where `window.electronAPI` is absent).
- **CQRS wrapper**: `toju-app/src/app/infrastructure/persistence/electron-database.service.ts` wraps `api.command()` / `api.query()` with typed helpers; product-client domains use this rather than calling CQRS directly.
- **Per-domain consumers**: file I/O (`attachment`), theme (`theme`), profile-avatar, notifications, idle (used by presence), and game-activity domains each inject the bridge to reach their respective IPC slice.
---
## Error handling
`electron/CONTEXT.md` says:
> IPC handler errors are translated to typed error envelopes before crossing back into the renderer — the renderer never sees a raw `Error` from main.
In practice today:
- The CQRS layer throws raw `Error` objects on unknown `type` (caller sees the serialized message).
- Most `electron/ipc/system.ts` handlers catch errors and return structured response objects (e.g. `{ opened: false, reason: string }`), but the shape is per-handler, not centralised.
- There is no global error-envelope wrapper around `ipcMain.handle`.
**TODO**: reconcile the CONTEXT.md invariant with reality — either introduce a shared error-envelope wrapper or update the invariant to match the per-handler convention. Until then, treat error shapes as a per-method concern.
---
## Technical implementation
- **Preload**: `electron/preload.ts` (single source of truth for the exposed surface).
- **Registration**: `electron/ipc/index.ts` calls three setup functions at app boot — `setupCqrsHandlers`, `setupSystemHandlers`, `setupWindowControlHandlers`.
- **System handlers**: `electron/ipc/system.ts` (~40 channels, ~780 lines).
- **Window controls**: `electron/ipc/window-controls.ts` (3 channels, fire-and-forget).
- **CQRS handlers**: `electron/ipc/cqrs.ts` plus typed command/query unions in `electron/cqrs/types.ts` and per-handler implementations under `electron/cqrs/handlers/`.
- **Local SQLite access** is gated behind CQRS — no other channel exposes the database directly. See `electron/data-source.ts` and `electron/entities/`.
## Invariants
- The renderer never imports `electron`, Node APIs, or TypeORM directly. (Enforced by Electron's `contextIsolation` + no `nodeIntegration`.)
- Every method on `window.electronAPI` has exactly one IPC channel and exactly one main-process handler.
- Schema mutations go through a TypeORM migration in `electron/migrations/`; raw SQL never crosses the IPC bridge.
- All file access is path-based on the main side, URL-based on the renderer side (`getFileUrl`).
## Testing
- `electron/plugin-library.spec.ts` — plugin discovery (touches the same IPC path but tests the library, not the channel).
- `electron/idle/idle-monitor.spec.ts` — idle source unit test.
- **TODO**: no spec covers `preload.ts` exposure, the system handler set, the CQRS dispatcher, or the error path. Renderer-side `ElectronBridgeService` spec status not verified.
## Security considerations
- `contextIsolation: true` + no `nodeIntegration` in the renderer; `electron/preload.ts` is the only crossing.
- Adding a channel requires touching both `preload.ts` and `electron/ipc/`. There is no dynamic channel registration.
- File-system handlers should validate paths against user-data scope — TODO: audit `system.ts` for path-traversal protections beyond what the plugin loader does.
- Deep-link handling: `consumePendingDeepLink` returns a queued URL; validation lives in renderer routing. TODO: confirm allow-list / scheme filtering on the main side.
## Performance considerations
- IPC traffic is per-call serialization; large payloads (file chunks, attachment imports) go via `readFileChunk` + offsets instead of single `readFile` to avoid blocking the main process.
- CQRS calls hit the local SQLite database synchronously inside the main process. There is no batching layer.
## Known issues and limitations
- **Documented vs. actual API name** — the `window` global is `electronAPI`, not `api`. CONTEXT.md uses `window.api.*`. Reconcile in a future cleanup.
- **No typed error envelope** despite the CONTEXT.md invariant.
- **No preload-surface test** — additions are caught only at runtime / lint.
## Related features
- **[plugin-system](./plugin-system.md)** — surfaces `getLocalPluginsPath`, `listLocalPluginManifests`, and plugin data CQRS commands through this bridge.
- **[websocket-envelopes](./websocket-envelopes.md)** — the realtime path that bypasses the bridge; included here only to delineate the two surfaces.
- **[voice-signaling](./voice-signaling.md)** — uses `getSources` and the Linux audio routing methods for screen-share media capture.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

View File

@@ -0,0 +1,195 @@
# Messaging
> **Area:** messaging
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
Messaging in Toju covers two transports that share a single domain model. Server-channel chat is broadcast by the signaling server over WebSocket — fire-and-forget, no server-side persistence today. Direct messages (1:1 and group DMs) are peer-to-peer over the WebRTC chat data channel, with a signaling-server fallback when no data channel is open and an offline queue for when neither path is available. On both transports the client maintains a **monotonic delivery state machine** per message and a **chunked inventory-sync protocol** that lets two peers reconcile missing history without flooding the link.
This document is the cross-context contract: envelope names, sync protocol, delivery states, edit/delete rules, and storage decisions. The product-client domain READMEs at `toju-app/src/app/domains/chat/README.md` and `toju-app/src/app/domains/direct-message/README.md` cover internal NgRx state and effects; the wire shapes live in [websocket-envelopes](./websocket-envelopes.md).
## Responsibilities
- Send, edit, and delete server-channel chat messages over WebSocket (`chat_message`, `edit_message`, `delete_message`).
- Send, edit, and delete direct messages over the WebRTC data channel with signaling fallback.
- Carry typing indicators on server-channel chat (`user_typing`).
- Reconcile peer history via the inventory-sync protocol (chunked, capped backfill).
- Drive a monotonic delivery state machine: `QUEUED → SENT → DELIVERED → ACKNOWLEDGED`.
- Persist direct messages locally; the server keeps no message store.
This area does **not** own:
- Attachment payloads or the chunked file-transfer protocol → [attachments](./attachments.md).
- RTC negotiation that brings the data channel up → [voice-signaling](./voice-signaling.md).
- Permission to send a message (`writeMessages`, `manageMessages`, channel overrides, slowmode hint) → [access-control](./access-control.md).
- The wire shape of every envelope used here → [websocket-envelopes](./websocket-envelopes.md).
## Key concepts
- **Server-channel message** — broadcast through the signaling server to every connected peer of a server. No server-side persistence in the current build.
- **Direct message** — point-to-point or group P2P message, persisted locally per-user via Electron CQRS (and via `localStorage` on browser fallback today — TODO confirm).
- **Conversation** — 1:1 or group DM thread. Group DMs can be created by adding a third participant to a 1:1, which spawns a new conversation while preserving the original.
- **Inventory event** — peer-to-peer announcement of "here is what I have for this conversation/channel"; the receiver replies with a request for missing pieces.
- **Sync batch** — chunked response carrying up to 200 messages per envelope, capped at 1000 messages of backfill per inventory exchange.
- **Delivery state** — monotonic enum on a direct message: `QUEUED (0) → SENT (1) → DELIVERED (2) → ACKNOWLEDGED (3)`. Defined in the chat-events shared kernel; advanced via `advanceDirectMessageStatus`.
- **Peer-delivery service** — `PeerDeliveryService` (`toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts`) — the dispatcher that tries the data channel first, then the signaling forward, then the offline queue.
---
## Transports
### Server-channel chat (WebSocket)
- Client sends `chat_message` to the signaling server; `handleChatMessage` (`server/src/websocket/handler.ts:274`) validates the user is in the target server and broadcasts to every other connection.
- `edit_message` and `delete_message` follow the same fan-out path.
- `user_typing` (`handleTyping`, `handler.ts:309`) is broadcast as a transient signal — no persistence, no sync, no delivery state.
- The server **does not persist** these envelopes. Late joiners do not see chat history older than their join — they can request it from peers via the inventory protocol if any peer present has it stored locally.
### Direct messages (WebRTC data channel)
- DMs ride the `chat`-labelled data channel established alongside each voice peer connection (see [voice-signaling](./voice-signaling.md)).
- `PeerDeliveryService` is the dispatcher. For each outgoing event it:
1. Tries every open data channel to a `recipients`-listed peer.
2. If no data channel is available to a recipient, falls back to a signaling-server `forwardPeerMessage` envelope so the server forwards it to that peer's connection.
3. If neither path is open, enqueues the event in `OfflineMessageQueueService` and replays on `peerConnected$` / `networkRestored$`.
- The server **forwards** DM envelopes opaquely — no inspection, no persistence.
### Storage
- **Direct messages** persist via Electron CQRS — see [ipc-bridge](./ipc-bridge.md). Each user has their own local TypeORM database; messages are written via `save-direct-message` / equivalent commands.
- **Server-channel chat** persists via `DatabaseService` (Electron CQRS) when running on desktop, or in IndexedDB when running purely in browser. TODO: confirm the IndexedDB code path.
- The signaling server holds **zero** message bytes at rest today. Re-deploys lose nothing because there is nothing to lose.
---
## Inventory / sync protocol
Both transports share the same inventory shape, defined in `toju-app/src/app/shared-kernel/chat-events.ts`:
- `ChatInventoryEvent` — sender broadcasts "for conversation X I have messages with these ids and last-modified timestamps" (capped at 1000 entries).
- `ChatSyncBatchEvent` — receiver replies with a chunked batch of full message payloads, **up to 200 per envelope**, repeated until the requested set is satisfied or 1000 messages have been returned.
Rules:
- Inventory is **additive**`mergeIncomingMessage` / `upsertDirectMessage` only insert or update; a sparser peer never wipes a richer peer's history.
- Reactions and attachment-link changes are reconciled by comparing per-message `lastModifiedAt`; the higher wins.
- The 1000-message ceiling is per inventory exchange, not per conversation lifetime; an older history can be filled in piecewise across multiple inventory cycles.
The same protocol is reused for the chat domain (server channels) and the direct-message domain. The implementation lives in `toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts` and is invoked from the direct-message effects via `DirectMessageService.requestSync()`.
---
## Delivery state machine
For DMs, every outgoing message has a `status`:
| Value | Numeric | Meaning |
|-------|---------|---------|
| `QUEUED` | 0 | Composed locally; no transport attempt has succeeded yet. |
| `SENT` | 1 | At least one transport (data channel or signaling forward) has accepted the payload. |
| `DELIVERED` | 2 | At least one recipient has acknowledged receipt at the application layer. |
| `ACKNOWLEDGED` | 3 | The full recipient set has acknowledged (1:1: the one recipient; group: every participant). |
Transitions are advanced via `advanceDirectMessageStatus`, which **only advances** — a higher value is never replaced by a lower one. A retried message that succeeds after a queue replay can therefore move `QUEUED → SENT` but never `DELIVERED → SENT`.
Server-channel messages do not carry an application-level delivery state today (the server broadcast is fire-and-forget); the UI treats them as `SENT` once the WebSocket accepts the frame.
---
## Edit and delete
- `edit-message` / `delete-message` events carry the original `messageId`. On both transports, the receiver locates the existing row (by id) and applies the mutation via `applyMutation`.
- DMs use the same envelope types but ride the data channel / signaling-forward fabric.
- Edits are last-writer-wins by `editedAt`. A delete removes the message body but keeps a tombstone with `deletedAt` so peers that haven't yet seen the delete can converge on the next inventory sync.
TODO: `applyMutation` does not currently verify the mutation originated from the original author. A non-cooperating client could send `edit-message` for someone else's message and a receiver would accept it. Confirm and either harden client-side or document the trust model.
---
## Typing indicators
- Server-channel only. DMs do not have a typing indicator today.
- Sent as `user_typing`; broadcast to a server scope.
- Transient: no persistence, no sync replay, no delivery state.
---
## Business rules and invariants
- The signaling server is **not authoritative** for messaging. `handleChatMessage` only broadcasts; there is no server-side message log.
- The delivery state machine is **monotonic**`advanceDirectMessageStatus` never moves status backwards.
- DM envelopes are **ignored** unless the local user appears in `participants` / `recipients` (or has an existing local conversation matching the id).
- Inventory merges are **additive**`mergeIncomingMessage` / `upsertDirectMessage` never delete or downgrade a richer local row.
- A 1:1 → group upgrade **preserves** the original 1:1 history; the group is a new conversation.
- Edits / deletes are reconciled by `lastModifiedAt` / `editedAt` / `deletedAt`.
---
## Technical implementation
### Server
- WS handlers — `server/src/websocket/handler.ts`: `handleChatMessage` (line 274), `handleTyping` (309); DM forwarding via `forwardPeerMessage` / `forwardRtcMessage`.
- No CQRS, no entities, no migrations: server messaging is broadcast-only.
### Product client
- Chat domain — `toju-app/src/app/domains/chat/`: services, effects, sync rules at `domain/rules/message-sync.rules.ts`.
- Direct-message domain — `toju-app/src/app/domains/direct-message/`: `DirectMessageService`, `PeerDeliveryService` (`application/services/peer-delivery.service.ts`), offline queue.
- Shared kernel — `toju-app/src/app/shared-kernel/chat-events.ts` (`ChatInventoryEvent`, `ChatSyncBatchEvent`, `chat_message`, `edit-message`, `delete-message`, `direct-message-sync`, `direct-message-sync-request`).
- Persistence — Electron CQRS commands for DMs (see [ipc-bridge](./ipc-bridge.md)); `DatabaseService` for server-channel chat.
### Electron
- DM persistence — TypeORM entity (likely `MessageEntity` and a DM-specific row) + CQRS handlers. Backup / restore is part of the Electron data-management surface.
---
## Testing
- TODO: chat domain has zero `*.spec.ts` files at time of writing.
- TODO: no dedicated server-side spec for `handleChatMessage`, `handleTyping`, or `forwardRtcMessage`.
- TODO: confirm specs for `PeerDeliveryService` and `OfflineMessageQueueService`.
- E2E: `e2e/tests/chat/chat-message-features.spec.ts` covers happy-path chat and attachment sync between users.
---
## Performance considerations
- Inventory batch cap: **200 messages per envelope**.
- Inventory backfill cap: **1000 messages per inventory exchange**.
- `chat_message` broadcast is O(N) over connected peers of the server; no fan-out batching.
- DMs incur O(recipients) data-channel writes (or signaling forwards) per send; large group DMs amplify per-message cost linearly.
---
## Security considerations
- **No end-to-end encryption.** P2P traffic over the data channel is DTLS-encrypted by WebRTC; signaling-forwarded fallback is plain WebSocket; either way the local TypeORM database stores plaintext.
- **`applyMutation` does not verify authorship** on incoming `edit-message` / `delete-message` events. TODO above.
- **No server-side rate limiting** on `chat_message`. A non-cooperating client can flood a server's broadcast.
---
## Known issues and limitations
- **No server-side chat history.** Late joiners depend on peers having local history to replay via inventory sync.
- **No spec coverage** for the chat domain.
- **DM authorship is not verified** by `applyMutation`.
- **No DM typing indicator.**
- **`OfflineMessageQueueService` retry policy** is currently driven by `peerConnected$` / `networkRestored$` events only — there is no scheduled retry; a stuck queue requires one of those events to fire. TODO: confirm behavior across reconnects.
---
## Related features
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of every envelope here.
- **[attachments](./attachments.md)** — file payloads ride alongside chat events on the data channel.
- **[voice-signaling](./voice-signaling.md)** — establishes the data channel DMs ride on.
- **[ipc-bridge](./ipc-bridge.md)** — exposes the CQRS persistence DMs and server chat use.
- **[access-control](./access-control.md)** — gates write permissions and slowmode.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

View File

@@ -0,0 +1,180 @@
# Plugin System
> **Area:** plugin-system
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
Plugins extend Toju's renderer with bundled or local ES modules that can publish events into a server, register UI contributions (pages, panels, actions, channel sections, embed renderers), store per-user or per-server data, and exchange messages over P2P. They are described by a typed manifest, gated by an explicit capability grant model, and coordinated across three subdomains: the Electron plugin loader (`electron/plugin-library.ts`), the renderer plugin runtime (`toju-app/src/app/domains/plugins/`), and the server's plugin-support surface (`server/src/routes/plugin-support.ts`). This area documents the contract those three sides share.
## Responsibilities
- Define the canonical plugin manifest shape (`TojuPluginManifest`) and its semantic versioning, dependency, and capability requirements.
- Discover local plugin manifests from disk in Electron and surface them to the renderer.
- Load plugin modules — local file://, http(s)://, or bundled — into the renderer and run their lifecycle hooks.
- Gate every host API call by an explicit capability grant.
- Persist server-scoped plugin requirements and event definitions on the signaling server, broadcasting changes to connected clients.
This area does **not** own:
- The wire format of `plugin_event` envelopes themselves — those live with [websocket-envelopes](./websocket-envelopes.md); this area defines the **validation rules** applied to them.
- IPC plumbing for plugin manifest discovery — that's the [ipc-bridge](./ipc-bridge.md) surface.
- Per-plugin business logic — that lives in the plugin's own code.
## Key concepts
- **Manifest** — `TojuPluginManifest` (`toju-app/src/app/shared-kernel/plugin-system.contracts.ts`). Required: `id`, `title`, `description`, `version`, `apiVersion`, `kind`, `schemaVersion` (fixed `1`), `compatibility.minimumTojuVersion`. Optional: `entrypoint`, `bundle`, `capabilities[]`, `events[]`, `data[]`, `ui`, `settings`, `relationships`, `authors`, `pluginUser`, `scope`, `homepage`, `changelog`, `license`, `readme`, `load.priority`.
- **Capability** — a string ID (e.g. `messages.send`, `events.server.publish`, `ui.pages`, `storage.local`). Plugins declare what they need in `capabilities[]`; the host enforces grants per plugin.
- **Plugin event** — a declared `{ eventName, direction, scope, schema?, maxPayloadBytes? }` tuple. `direction` is `clientToServer | serverRelay | p2pHint`; `scope` is `server | channel | user | plugin`.
- **Capability grant** — an entry in `metoyou_plugin_capability_grants` (localStorage + desktop file) recording user consent for a `(pluginId, capability)` pair.
- **Activation context** — `TojuPluginActivationContext` — what a plugin module receives in its `activate(context)` hook: `pluginId`, `manifest`, `api` (the capability-gated `TojuClientPluginApi`), and a `subscriptions[]` cleanup list.
- **Local plugin** — a folder under `${app.getPath('userData')}/plugins/<id>/` containing `toju-plugin.json` (preferred) or `plugin.json` and resolved relative assets.
---
## Manifest contract
Declared in `toju-app/src/app/shared-kernel/plugin-system.contracts.ts` (the source of truth on the renderer side; the Electron loader treats the manifest as `unknown` until the renderer validates it).
Highlights:
- **`schemaVersion: 1`** — fixed; bump only with a coordinated migration.
- **`apiVersion`** — declares which host API surface the plugin expects.
- **`kind`** — distinguishes plugin types (currently a string union; see file for exact members).
- **`bundle.url`** + optional `bundle.entrypointUrl` — for remote / store-installed plugins.
- **`entrypoint`** — relative path within the local plugin folder; rejected if it escapes `pluginRoot`.
- **`relationships.{requires,optional,conflicts,before,after}`** — `pluginId` + optional `versionRange`. Resolved at activation; missing required dependencies block activation.
- **`events[]`** — registered with the server via `plugin-support` when the plugin is required by a server. The server uses these definitions to validate inbound `plugin_event` envelopes.
- **`data[]`** — `{ key, scope: server|channel|user|plugin, storage: local|serverData, schema? }`.
- **`load.priority`** — `bootstrap | high | default | low`; controls ordering when several plugins are activated together.
- **`pluginUser`** — synthetic user identity for messages a plugin posts on its own behalf.
---
## Discovery & loading
### Local discovery (Electron main)
`electron/plugin-library.ts`:
- Scans `${app.getPath('userData')}/plugins/` **one level deep** for plugin folders.
- For each folder, reads `toju-plugin.json` (preferred) or `plugin.json` as raw JSON. No schema validation at this layer.
- Resolves relative paths (`entrypoint`, `readme`) against the plugin root, rejecting `..`, absolute paths, or anything that escapes the root via `isPathInside()` realpath check.
- Returns `LocalPluginDiscoveryResult { plugins, errors, pluginsPath }` where each `plugins[i]` is a `LocalPluginManifestDescriptor` carrying the raw manifest, manifest path, plugin root, plugin-root `file://` URL, optional entrypoint and readme paths, and a `discoveredAt` timestamp.
Exposed to the renderer through the [ipc-bridge](./ipc-bridge.md) as `listLocalPluginManifests` and `getLocalPluginsPath`.
### Renderer runtime
`toju-app/src/app/domains/plugins/`:
- **`PluginHostService`** — orchestrates lifecycle: `discoverLocalPlugins`, `registerLocalManifest`, `activate`, `deactivate`, `reload`, `loadPluginModule`.
- **`PluginClientApiService`** — constructs the capability-gated facade `TojuClientPluginApi` per plugin. Subsystems: `channels`, `events`, `messages`, `messageBus`, `p2p`, `profile`, `users`, `roles`, `server`, `attachments`, `media`, `storage`, `serverData`, `clientData`, `ui`, `logger`, `context`.
- **`PluginCapabilityService`** — `grant(pluginId, capability)`, `revoke()`, `grantAll(manifest)`, `assert(pluginId, capability)`. Storage in `metoyou_plugin_capability_grants` (localStorage + desktop file).
- **`PluginMessageBusService`** — plugin-scoped pub/sub with topic, optional channel/peer targeting, optional message replay.
- **`PluginStorageService`** — split storage paths for `local` and `serverData` scopes.
- **`PluginUiRegistryService`** — central registry of UI contributions consumed by `plugin-render-host`, `plugin-page-host`, `plugin-action-menu`.
- **`PluginRequirementStateService`**, **`PluginDesktopStateService`** — state slices.
### Module loading
- Entrypoint URL is `file://` for local plugins, `http(s)://` for remote, or the `bundle.url` for bundled.
- Bytes are fetched, wrapped in a `Blob`-backed object URL, then imported via dynamic `import()` so devtools and stack traces resolve.
- `GuardedPluginMutationObserver` wraps observer callbacks to catch plugin errors and break infinite redispatch loops.
### Lifecycle states
From `plugin-runtime.models.ts`: `discovered → validated → blocked | loading → ready → loaded → failed | unloading → unloaded → disabled`.
### Module contract
```ts
export interface TojuClientPluginModule {
activate?(context: TojuPluginActivationContext): void | Promise<void>;
deactivate?(context: TojuPluginActivationContext): void | Promise<void>;
ready?(context: TojuPluginActivationContext): void | Promise<void>;
onServerRequirementsChanged?(context, snapshot): void | Promise<void>;
onPluginDataChanged?(context, event): void | Promise<void>;
}
```
---
## Server surface (`server/src/routes/plugin-support.ts`)
Each endpoint scoped to `:serverId`. Permission `manageServer` enforced for mutations via `server-permissions.service.ts`.
- `GET /:serverId/plugins``PluginRequirementsSnapshot` — full set of required/optional plugins + event definitions for the server.
- `PUT /:serverId/plugins/:pluginId/requirement` — upsert a requirement: `status: required | optional | recommended | blocked | incompatible`, `installUrl?`, `sourceUrl?`, `manifest?`, `versionRange?`.
- `DELETE /:serverId/plugins/:pluginId/requirement` — remove.
- `PUT /:serverId/plugins/:pluginId/events/:eventName` — register or update an event definition (`direction`, `scope`, `schema?`, `maxPayloadBytes?`).
- `DELETE /:serverId/plugins/:pluginId/events/:eventName` — delete.
- `GET|PUT|DELETE /:serverId/plugins/:pluginId/data/:key`**disabled** on the signaling server (returns HTTP 410). Plugin data on the server is intentionally out of scope.
Identifier patterns:
- `pluginId`: `/^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/`
- `eventName`: `/^[a-z][a-z0-9.:-]{1,126}[a-z0-9]$/`
Changes broadcast to connected clients via the [websocket-envelopes](./websocket-envelopes.md) — the matching `PluginRequirementsSnapshot` is also delivered as part of the `join_server` and `view_server` responses.
---
## Plugin event validation
`server/src/services/plugin-support.service.ts` exposes `validatePluginEventEnvelope()`. The `plugin_event` envelope handler (`server/src/websocket/handler.ts`) calls it before broadcasting. Validation checks:
- `eventName` is registered for `pluginId` on `serverId`.
- `direction` permits the source (clientToServer vs p2pHint vs serverRelay).
- `payload` size ≤ `maxPayloadBytes` (default 64 KB).
- If `schema` was declared in the manifest, the payload conforms — TODO: confirm the schema dialect (looks like JSON Schema subset).
## Security considerations
- **Plugins run in the renderer's JS context.** There is no true sandbox: plugins have DOM access, can read/write `localStorage`, and can issue `fetch` requests subject to the renderer's CSP.
- **Capability model is the primary security boundary.** Every method on `TojuClientPluginApi` calls `PluginCapabilityService.assert(pluginId, capability)`. Missing grant → `PluginCapabilityError`.
- **Path traversal** in the local plugin loader is blocked by `isPathInside()` realpath checks (`electron/plugin-library.ts`).
- **Payload bounds** on plugin events: default 64 KB, configurable per event definition.
- **No code signing or integrity verification.** Plugins are trusted to the extent the user granted capabilities. The plugin store flow is documented in `docs-site/docs/plugin-development/`.
- **TODO**: review whether `bundle.url` fetches go through a CSP / allow-list or are unbounded.
## Configuration
- **Local plugins path**: `${app.getPath('userData')}/plugins/` (Electron). Exposed as `getLocalPluginsPath`.
- **Capability grants**: `metoyou_plugin_capability_grants` in localStorage; mirrored to a desktop file via `PluginDesktopStateService`.
- **Server-side persistence**: plugin requirements and event definitions are stored in the server's database; entities and migrations live alongside other server entities (TODO: enumerate the specific entities).
## Testing
- **Electron**: `electron/plugin-library.spec.ts` — covers valid/invalid JSON, path traversal rejection, escaping entrypoints. Uses fixture `TEST_PLUGIN_FIXTURE_DIR`.
- **Renderer**: `plugin-host.service.spec.ts`, `plugin-store.service.spec.ts`, `local-plugin-discovery.service.spec.ts`.
- **Server**: `server/src/websocket/handler-plugin.spec.ts``plugin_event` validation flow.
- **E2E**: `e2e/tests/.../plugin-support-api.spec.ts`, `plugin-manager-ui.spec.ts`, `plugin-api-two-users.spec.ts`.
## Known issues and limitations
- **Server-side plugin data is intentionally disabled** (`410 Gone` on `*/data/:key` routes). Plugins must currently treat `serverData` storage as not yet implemented on the signaling server. TODO: clarify whether this is a permanent boundary or scheduled work.
- **No true sandbox** for plugin execution. The capability model is the only restraint between a plugin and the renderer's globals.
- **Manifest validation is renderer-side.** The Electron loader treats manifests as raw JSON; malformed or hostile manifests are caught when the renderer registers them.
- **No host-side plugin allow-list** beyond per-server requirements — a user can install any local plugin.
## Related features
- **[ipc-bridge](./ipc-bridge.md)** — surfaces `listLocalPluginManifests`, `getLocalPluginsPath`, and the plugin CQRS commands (`SavePluginData`, `DeletePluginData`, `GetPluginData`).
- **[websocket-envelopes](./websocket-envelopes.md)** — defines the `plugin_event` envelope this area validates.
- **[server-directory](./server-directory.md)** — `join_server` / `view_server` responses include `PluginRequirementsSnapshot` for the joined server.
## Documentation for plugin authors
Author-facing docs ship with the desktop app via Docusaurus:
- `docs-site/docs/plugin-development/create-a-plugin.md`
- `docs-site/docs/user-guide/plugins.md`
- `docs-site/docs/developer/llm-plugin-builder-guide.md`
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

View File

@@ -0,0 +1,196 @@
# Presence
> **Area:** presence
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
Presence in Toju covers everything that signals "who is here, where, and doing what" — connection lifecycle (`join_server`, `leave_server`), availability status (`online / away / busy / offline`), profile metadata propagation, voice-room membership, and the current-game indicator. The signaling server **forwards and deduplicates** presence over WebSocket but never persists it: every restart of the server resets all presence state to nothing, and every reconnect of a client re-derives the world from scratch via a `server_users` snapshot.
Several signals contribute. Status comes from idle detection in the renderer (Electron `powerMonitor` or browser fallback) and from explicit user choices. Voice membership is **client-derived** from observed `voice_state` broadcasts — the server never tracks who is in a voice room. Game activity is scanned in the renderer using Electron-IPC process inspection, resolved via the server's RAWG-backed `/games/match` API, and broadcast directly to peers over the WebRTC data channel — it never touches the signaling server.
## Responsibilities
- Track which users are connected to which server and broadcast joins/leaves with multi-device dedup.
- Carry profile metadata (`displayName`, `description`, `profileUpdatedAt`) so peers can render rich identity without a separate lookup.
- Propagate availability status (`online / away / busy / offline`) as users choose, idle, or wake.
- Surface voice-room membership to peers via the `voice_state` envelope.
- Surface current-game activity to peers via the chat data channel.
This area does **not** own:
- The WebSocket envelope shape — see [websocket-envelopes](./websocket-envelopes.md).
- RTC negotiation, peer connection lifecycle, RNNoise — see [voice-signaling](./voice-signaling.md).
- Server-side account records, credentials, or the `identify` handshake's authentication semantics — see [authentication](./authentication.md).
- Authorization for joining a server (membership / bans / invites) — see [access-control](./access-control.md).
## Key concepts
- **`oderId`** — client-asserted identity. The deduplication key across multiple sockets and devices.
- **`connectionScope`** — opaque string (typically the signal URL). Used together with `oderId` to coexist multi-URL sockets without an eviction loop.
- **Status** — `'online' | 'away' | 'busy' | 'offline'`. `VALID_STATUSES` lives in `server/src/websocket/handler.ts`. Client maps incoming `offline` to a local `disconnected` rendering tag.
- **Manual vs automatic status** — manual user choices override the idle-detector.
- **Voice presence** — derived client-side from `voice_state` broadcasts; the signaling server has no voice-room model.
- **Game activity** — currently-detected game; resolved via `/games/match` and announced to peers over the data channel as a `game-activity` chat event.
- **`ConnectedUser`** — server-side per-connection row (`server/src/websocket/types.ts`); see [authentication](./authentication.md) for the full shape.
---
## Envelopes (consumed and emitted)
Schemas live in [websocket-envelopes](./websocket-envelopes.md). Presence-relevant types:
- `identify` — first envelope a client sends; updates profile metadata. Re-broadcasts `user_joined` per joined server when profile fields change.
- `join_server` / `leave_server` — connection joins/leaves a specific server scope; gated by `authorizeWebSocketJoin` (see [access-control](./access-control.md)).
- `server_users` — full peer roster sent to a connection when it joins a server; the only "snapshot" envelope.
- `user_joined` — broadcast to peers on a server when a new *identity* arrives (i.e. no other connection of the same `oderId` already had this server).
- `user_left` — broadcast to peers when an identity fully releases a server; payload includes `serverIds` listing the servers the user is still in elsewhere.
- `status_update` — availability status change (`online / away / busy / offline`).
- `voice_state` — broadcast to a server when the user enters/leaves voice or toggles mute/deafen. Carries `roomId`, `voiceGateway`, mute/deafen flags. Voice membership is reconstructed from these events client-side.
- `keepalive` — bumps `lastPong` on the server to keep the connection from being reaped.
- `access_denied` — server response when authorization for `join_server` fails.
Game activity events ride the WebRTC **chat data channel** as `game-activity` chat events — not as WebSocket envelopes.
---
## Lifecycle
A connection moves through these stages:
1. **WebSocket connect** — server allocates a `ConnectedUser` row keyed by `connectionId`.
2. **`identify`** — client claims `oderId`, profile metadata, and an optional `connectionScope`. See [authentication](./authentication.md) for the handshake details.
3. **`join_server`** — gated by `authorizeWebSocketJoin`. On success, the server adds the server to `user.serverIds`, sends a private `server_users` snapshot to the joiner, and broadcasts `user_joined` to other peers on that server *only if* this is a new identity for the server (multi-device dedup).
4. **Steady state**`status_update`, `voice_state`, profile-bearing `identify` updates, and chat / RTC envelopes flow. The server bumps `lastPong` on every inbound frame.
5. **`leave_server` / disconnect** — the server removes the server from `user.serverIds`. `user_left` is broadcast to peers *only* once every connection of the same `oderId` has released the server. The payload's `serverIds` field reports which servers the identity is still in, so clients can distinguish "moved tabs" from "fully left."
6. **Dead-connection sweep** — every `PING_INTERVAL_MS = 30 000` the server sweeps; any connection with `lastPong` older than `PONG_TIMEOUT_MS = 45 000` is closed and processed as a disconnect (`server/src/websocket/index.ts`).
---
## Multi-device deduplication
`broadcastToServer` (`server/src/websocket/broadcast.ts`) deduplicates fan-out by `oderId`, so a user logged in on two devices sees each peer event once.
`handleJoinServer` (`handler.ts:155`) only emits `user_joined` when the join is a **new identity membership** — i.e. no other connection of the same `oderId` already held the server. Renaming a tab or opening a second window does not produce spurious join notifications.
Symmetrically, `handleLeaveServer` only emits `user_left` when no other connection of the same `oderId` still holds the server. The `serverIds` field on the payload lets clients see "the identity is still in these other servers" rather than treating a tab close as a full logout.
`connectionScope` keeps this stable across multi-signal-URL deployments: an identity that opens connections to two signal URLs is still one identity from the broadcast layer's perspective, but the per-scope stale-eviction does not loop.
---
## Status
Two writers update status:
- **Manual** — the user picks `online / away / busy / offline` from the UI. The chosen value is sent as a `status_update` envelope and persists locally.
- **Automatic** — `UserStatusService` (`toju-app/src/app/core/services/user-status.service.ts`) listens to Electron `powerMonitor` events (suspend, resume, lock, unlock) when running on desktop, or falls back to a 15-minute idle timer in the browser. It emits `status_update` via `RealtimeSessionFacade.sendRawMessage`.
Manual overrides automatic for the session — explicit user input prevents the idle detector from overwriting `away` back to `online`.
Server-side, `handleStatusUpdate` (`handler.ts:337`) validates the value against `VALID_STATUSES`, mutates `user.status`, and broadcasts the event to every server the user is in.
---
## Game activity
`GameActivityService` (`toju-app/src/app/domains/game-activity/application/game-activity.service.ts`) is the renderer-side scanner:
- Polls every 560 seconds (default 10 s).
- Asks Electron for active and running process names via the IPC bridge (`getActiveGameCandidate`, `getRunningProcessNames` — see [ipc-bridge](./ipc-bridge.md)).
- Resolves candidates to RAWG metadata via `POST /games/match` on the signaling server.
- Broadcasts the result to peers as a `game-activity` chat event on the WebRTC chat data channel.
The signaling server is **not** in the broadcast path for game activity — it only matches process names to RAWG entries. Once a peer connection exists, the game-activity envelope flows P2P.
---
## Business rules and invariants
- The signaling server **forwards** presence but **never persists** it. Server restart = full reset; clients rederive via `server_users`.
- Presence is **per-connection**; identity is reconstructed at broadcast time by collapsing connections that share `oderId`.
- `user_joined` is emitted **only** on a new-identity membership; `user_left` **only** on full release of a server by an `oderId`.
- `identify` is the canonical channel for profile-metadata updates. Profile-bearing `identify` envelopes rebroadcast `user_joined` only if at least one of `displayName` / `description` / `profileUpdatedAt` actually changed.
- **Voice room membership is never server-tracked.** It is client-derived from `voice_state` broadcasts.
- Manual status overrides automatic status for the session.
- Reconnection resets all presence state; the joiner's first `server_users` snapshot is authoritative for that server.
- Dead connections are reaped after 45 s of silence.
---
## Technical implementation
### Server
- WebSocket envelope handlers — `server/src/websocket/handler.ts`: `handleStatusUpdate` (line 337), `handleIdentify` (112), `handleJoinServer` (155), `handleLeaveServer` (224).
- Broadcast / dedup — `server/src/websocket/broadcast.ts`: `broadcastToServer`, `sendServerUsers`.
- Sweep / heartbeat — `server/src/websocket/index.ts` (`PING_INTERVAL_MS`, `PONG_TIMEOUT_MS`).
- `ConnectedUser` row — `server/src/websocket/types.ts`.
- Game-match HTTP — `server/src/routes/games.ts`, `server/src/services/game-matching.service.ts`.
### Product client
- Status writer — `toju-app/src/app/core/services/user-status.service.ts`.
- Game-activity scanner — `toju-app/src/app/domains/game-activity/application/game-activity.service.ts`.
- Voice presence — `toju-app/src/app/domains/voice-session/` (see [voice-signaling](./voice-signaling.md)).
- Presence sync into NgRx — `toju-app/src/app/store/rooms/room-state-sync.effects.ts`, `room-members-sync.effects.ts`.
- Presence reducers — `toju-app/src/app/store/users/`.
### Electron
- Process inspection IPC — `electron/preload.ts` (`getActiveGameCandidate`, `getRunningProcessNames`). See [ipc-bridge](./ipc-bridge.md).
- `powerMonitor` events — wired in `electron/` and surfaced to renderer via IPC events.
---
## Testing
- `server/src/websocket/handler-status.spec.ts` — status validation and broadcast.
- `toju-app/src/app/store/users/users-status.reducer.spec.ts` — status reducer.
- `toju-app/src/app/store/rooms/rooms-helpers-status.spec.ts` — room-level status aggregation.
- `toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts` — scanner + RAWG match.
- `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.spec.ts` — presence-relevant handling.
- TODO: no spec for multi-device `user_left` suppression.
- TODO: no spec for `identify`-triggered profile rebroadcast.
- TODO: no spec for pong-timeout reaping.
- TODO: no spec for client `offline → disconnected` rendering mapping.
- TODO: no E2E for cross-device presence dedup.
---
## Security considerations
- The `identify` claim is **not verified** — see [authentication](./authentication.md). Presence trust is inherited from that gap: a connection can claim any `oderId` and be broadcast as that user.
- Game-activity scanning surfaces local process names to the signaling server (via `/games/match`) and to peers (via the data channel). This is privacy-sensitive — there is no per-user opt-out documented today; if a user does not want process names leaving their machine, they need a `closeToTraySetting`-style toggle. TODO: confirm the current opt-out path.
- Status is self-asserted. `busy` does not enforce anything; it is purely a presence hint.
---
## Performance considerations
- `broadcastToServer` is O(N) per envelope, where N is the number of connections subscribed to the server. There is no fan-out batching.
- The 30 s ping cadence keeps idle connections cheap; the 45 s reaper keeps `connectedUsers` from leaking on TCP half-closes.
- `GameActivityService` defaults to a 10 s poll; user-configurable, clamped 560 s.
- `identify` rebroadcast is O(serversJoinedByThisConnection); negligible in practice.
---
## Known issues and limitations
- No server-side voice-room membership model means the only way to enumerate a room's occupants is to replay `voice_state` events client-side.
- Status idle detection in the browser falls back to a coarse 15-minute timer (no platform idle API).
- Game-activity opt-out and granularity (per-game blocklist) are not centrally documented; the `gameIgnoreList` setting lives in `electron/desktop-settings.ts` but is undocumented in renderer UX terms — TODO.
---
## Related features
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of every envelope this area uses.
- **[voice-signaling](./voice-signaling.md)** — consumes the `voice_state` broadcasts to drive RTC mesh.
- **[authentication](./authentication.md)** — owns the `identify` handshake and the heartbeat / reaping policy.
- **[access-control](./access-control.md)** — gates `join_server`, which precedes any presence broadcast for that server.
- **[ipc-bridge](./ipc-bridge.md)** — exposes `powerMonitor` events and process-list inspection used by status and game-activity.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

View File

@@ -0,0 +1,174 @@
# Server Directory
> **Area:** server-directory
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
The Server Directory is the public REST surface that lists joinable Toju chat servers, manages invites and join requests, gates membership (passwords, bans, ownership), and exposes moderation actions (kick / ban / unban). It is the only feature where the signaling server holds non-ephemeral, multi-user state: the persistent catalog of servers, their access rules, their memberships, and their pending join requests. The renderer's `server-directory` domain consumes this surface to render the "find a server" experience and to drive the join flow that eventually opens a WebSocket (see [websocket-envelopes](./websocket-envelopes.md)).
## Responsibilities
- Persist the catalog of servers, their access policy (public/private, password, max users), and ownership.
- Mint invites and accept invite redemptions.
- Track join requests on private servers and route owner decisions back to the requester.
- Track memberships and bans; enforce them on join attempts.
- Provide moderation primitives: kick, ban, unban — gated by role/owner permissions.
- Emit user-targeted notifications when a join request changes state.
This area does **not** own:
- Realtime presence, chat, or voice — those flow over the WebSocket once a user has joined (see [websocket-envelopes](./websocket-envelopes.md), [voice-signaling](./voice-signaling.md)).
- Per-channel permissions logic (lives in `server/src/services/server-permissions.service.ts` and is consumed by this area, but is reused beyond it).
## Key concepts
- **Server** — a joinable chat server. Persisted as `ServerEntity` (`servers` table).
- **Public / private** — `isPrivate` flag. Public servers appear in directory listings; private servers do not.
- **Invite** — an opaque token (`ServerInviteEntity`) that grants short-lived access to a specific server. Expires after `SERVER_INVITE_EXPIRY_MS` (10 days).
- **Join request** — a pending request on a private server (`JoinRequestEntity`), `pending → approved | denied`.
- **Membership** — a `ServerMembershipEntity` row, indexed by `serverId` + `userId`.
- **Ban** — a `ServerBanEntity` row, optionally `expiresAt`. Auto-pruned on the next join attempt for the banned user.
- **Heartbeat** — periodic `POST /:id/heartbeat` from the server owner's client that updates `lastSeen` and `currentUsers` on the directory entry.
---
## API Endpoints
All HTTP routes; no auth header — caller identity is supplied per-request in the body (`ownerId`, `actorUserId`, `userId`, `requesterUserId`). Identity is whatever the client claims; authorization is enforced against persisted state. Request body validation is **manual / defensive** (no zod or class-validator).
### `server/src/routes/servers.ts`
| Method | Path | Purpose | Auth |
|--------|------|---------|------|
| `GET` | `/` | List public servers. Query: `q`, `tags`, `limit`, `offset`. | None |
| `POST` | `/` | Create a server. Required body: `name`, `ownerId`, `ownerPublicKey`. | Self-asserted |
| `GET` | `/:id` | Fetch a single server. 404 if missing. | None |
| `PUT` | `/:id` | Update a server. Required body: `currentOwnerId`. Permission check via `canManageServerUpdate`. | Owner / role |
| `POST` | `/:id/join` | Join a server. Required body: `userId`. Optional: `password`, `inviteId`. Returns `signalingUrl`. | Self-asserted + access rules |
| `POST` | `/:id/invites` | Create an invite. Required body: `requesterUserId`. Delegates to `createServerInvite`. | Role permission |
| `POST` | `/:id/moderation/kick` | Kick a user. Required: `actorUserId`, `targetUserId`. Permission: `canModerateServerMember`. | Role permission |
| `POST` | `/:id/moderation/ban` | Ban a user. Required: `actorUserId`, `targetUserId`. Optional: `banId`, `reason`, `expiresAt`. | Role permission |
| `POST` | `/:id/moderation/unban` | Unban a user. Required: `actorUserId`. Permission: `manageBans`. | Role permission |
| `POST` | `/:id/leave` | Leave a server. Required body: `userId`. | Self-asserted |
| `POST` | `/:id/heartbeat` | Update `lastSeen` and `currentUsers`. Optional body: `currentUsers`. | None (TODO: confirm) |
| `DELETE` | `/:id` | Delete a server. Required body: `ownerId` (must match `server.ownerId`). | Owner |
| `GET` | `/:id/requests` | List pending join requests. Query: `ownerId`. | Owner |
### `server/src/routes/invites.ts`
- `GET /invites/:id` (API) — fetch invite metadata; `404` for expired or unknown invite.
- `GET /invites/:id` (page router) — server-rendered HTML preview of the invite (server info, owner, expiry); renders an offline state when the server is unreachable.
### `server/src/routes/join-requests.ts`
- `PUT /requests/:id` — update join-request status. Body: `ownerId`, `status`. Permission: `manageServer`. On success, calls `notifyUser` (WebSocket fan-out, see below).
### Standard error codes
`SERVER_NOT_FOUND`, `MISSING_USER`, `NOT_AUTHORIZED`, `BANNED`, `PASSWORD_REQUIRED`, `INVITE_EXPIRED`, plus 400 for missing required fields.
---
## CQRS handlers
`server/src/cqrs/` backs every mutation; routes are thin adapters around CQRS dispatch.
**Queries** (`server/src/cqrs/queries/handlers/`):
- `getAllPublicServers` — filtered by `isPrivate = 0`, loads relations.
- `getServerById`
- `getJoinRequestById`
- `getPendingRequestsForServer`
**Commands** (`server/src/cqrs/commands/handlers/`):
- `upsertServer` — also calls `replaceServerRelations` to sync `tags`, `channels`, `roles`, `roleAssignments`, `channelPermissions` atomically.
- `deleteServer`
- `createJoinRequest`
- `updateJoinRequestStatus` — emits a `notifyUser` event so the requesting user's client learns the outcome over WebSocket.
All handlers run inside TypeORM transactions where multi-table changes are involved.
---
## Persistence
### Entities (`server/src/entities/`)
- `ServerEntity` (table `servers`) — `id`, `name`, `description`, `ownerId`, `ownerPublicKey`, `passwordHash`, `isPrivate`, `maxUsers`, `currentUsers`, `icon`, `iconUpdatedAt`, `slowModeInterval`, `createdAt`, `lastSeen`.
- `ServerInviteEntity` (`server_invites`) — `id`, `serverId` (indexed), `createdBy`, `createdByDisplayName`, `createdAt`, `expiresAt` (indexed).
- `JoinRequestEntity` (`join_requests`) — `id`, `serverId` (indexed), `userId`, `userPublicKey`, `displayName`, `status` (default `pending`), `createdAt`.
- `ServerMembershipEntity` (`server_memberships`) — `id`, `serverId` (indexed), `userId` (indexed), `joinedAt`, `lastAccessAt`.
- `ServerBanEntity` (`server_bans`) — `id`, `serverId` (indexed), `userId` (indexed), `bannedBy`, `displayName`, `reason`, `expiresAt` (nullable), `createdAt`.
Related (referenced by `replaceServerRelations`): `ServerChannelEntity`, `ServerRoleEntity`, `ServerUserRoleEntity`, `ServerTagEntity`, `ServerChannelPermissionEntity`.
### Migrations (`server/src/migrations/`)
- `1000000000000-InitialSchema.ts``servers`, `users`.
- `1000000000001-ServerAccessControl.ts` — adds `passwordHash` to `servers`; creates `server_memberships`, `server_invites`, `server_bans` with indices.
- `1000000000002-ServerChannels.ts``server_channels`.
- `1000000000005-ServerRoleAccessControl.ts` — role/permission tables.
- TODO: locate the migration that created `join_requests` (not obvious from filenames; likely folded into an earlier migration).
---
## Renderer side
`toju-app/src/app/domains/server-directory/`:
- **API client**: `infrastructure/services/server-directory-api.service.ts``ServerDirectoryApiService` exposes `searchServers`, `getServers`, `getServer`, `findServerAcrossActiveEndpoints`, `registerServer`, `updateServer`, `requestJoin`, `createInvite`, `getInvite`, `kickServerMember`, `banServerMember`, `unbanServerMember`, `notifyLeave`, `sendHeartbeat`. Defensive coercion (`getNumberValue` / `getStringValue` / `getBooleanValue`) is used instead of schema validation.
- **State**: signal-based via `ServerEndpointStateService` (servers, active server) — not NgRx for this slice.
- **Facade**: `application/services/server-directory.service.ts` plus `application/facades/`.
- **Multi-endpoint awareness**: Toju supports several federated signaling endpoints; `findServerAcrossActiveEndpoints` queries each and merges results.
---
## Business rules
- **Public-only listing**: `GET /` only returns servers with `isPrivate = 0`. Private servers must be reached by ID + invite.
- **Owner immutability**: only `currentOwnerId` matching `server.ownerId` may update; only `ownerId` matching `server.ownerId` may delete.
- **Join order of checks** (on `POST /:id/join`): existence → ban check (auto-prune expired bans) → password check (if `passwordHash`) → invite check (if private and no invite) → membership upsert → return `signalingUrl`.
- **Invite expiry**: 10 days (`SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000`). Expired invites are pruned on access via `pruneExpiredServerAccessArtifacts()`.
- **Ban expiry**: optional `expiresAt`; auto-deleted on next join attempt for that user.
- **Join request notifications**: on `PUT /requests/:id`, after CQRS dispatch, `notifyUser` pushes the new status over WebSocket to any open connection for `userPublicKey` / `userId`.
## Security considerations
- **No authentication header.** All identity is self-asserted in the request body. Authorization is enforced by checking the claimed identity against persisted role/owner state.
- **Password storage**: `passwordHash` only; never the cleartext. TODO: confirm the hashing algorithm (likely bcrypt / scrypt — verify in `server/src/services/`).
- **SSRF**: routes in this area do not fetch user-supplied URLs, so the SSRF guard does not apply here (it applies to link-metadata, klipy, proxy).
- **No rate limiting** on directory or moderation routes — TODO: add brute-force protection on `POST /:id/join` for password attempts.
- **No CSRF** (REST + JSON body, no cookies in scope), but spam protection on `POST /` (server creation) is also TODO.
## Configuration
- `SERVER_INVITE_EXPIRY_MS` — currently hardcoded at 10 days. Not exposed via `data/variables.json`.
- Per-server `maxUsers`, `slowModeInterval`, `isPrivate`, `passwordHash` are operator-configurable via `PUT /:id`.
## Testing
- **Server-side**: no direct route specs for `servers.ts`, `invites.ts`, `join-requests.ts`. WebSocket-side handlers (`handler-status.spec.ts`, `handler-plugin.spec.ts`) cover adjacent concerns.
- **Renderer-side**: `application/services/server-endpoint-state.service.spec.ts`.
- **E2E**: TODO — verify whether the Playwright suite covers join / invite / moderation end-to-end.
- **Gap**: routes that mutate persistent state and accept self-asserted identity should ideally have integration tests against a real DB.
## Known issues and limitations
- **OpenAPI coverage is incomplete.** `server/src/routes/openapi-docs.ts` currently documents plugin-support endpoints only; server-directory endpoints are not listed.
- **No structured request validation library.** Inline manual checks are error-prone; consider zod once the team is ready.
- **No rate limiting / spam protection** on server creation or join attempts.
- **`join_requests` migration is undocumented** (file not located by inspection); confirm during the next schema change.
## Related features
- **[websocket-envelopes](./websocket-envelopes.md)** — `join_server` envelope re-uses this area's access rules via `authorizeWebSocketJoin`. `notifyUser` fan-out for join-request decisions is delivered over the same WebSocket.
- **[plugin-system](./plugin-system.md)** — `join_server` responses include the joined server's `PluginRequirementsSnapshot`.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

View File

@@ -0,0 +1,177 @@
# Voice & WebRTC Signaling
> **Area:** voice-signaling
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
Voice and screen-share in Toju are pure WebRTC mesh: peers establish RTCPeerConnections directly, while the signaling server only forwards SDP and ICE messages. This area covers the end-to-end flow — envelope routing, peer election, RTCPeerConnection lifecycle, RNNoise denoising, and the relationships between the three product-client domains involved: `voice-session`, `voice-connection`, and `direct-call`. Screen-share rides on the same peer connection; its UI orchestration is its own domain but the signaling path is shared.
## Responsibilities
- Negotiate WebRTC sessions between peers using `offer` / `answer` / `ice_candidate` envelopes forwarded by the signaling server.
- Elect an initiator deterministically when multiple peers arrive simultaneously, with a non-initiator fallback timer.
- Maintain the local audio pipeline: mic capture → optional RNNoise denoising → RTCPeerConnection sender.
- Track per-peer playback gain, mute, deafen, and speaking-activity state on the receive side.
- Mirror voice presence (`voice_state`) and direct-call signalling (`direct-call`) to other peers via the WebSocket.
This area does **not** own:
- The WebSocket envelope shape (see [websocket-envelopes](./websocket-envelopes.md)).
- Screen-share UI orchestration (its own domain at `toju-app/src/app/domains/screen-share/`); only the peer connection plumbing is shared.
- Persistent user settings beyond `voiceSettingsStorage` (audio device IDs, volumes, bitrate, latency profile, noise-reduction toggle, persisted to localStorage).
## Key concepts
- **Mesh** — every participant holds an `RTCPeerConnection` per other participant. No SFU / MCU.
- **Voice session** — high-level "user is currently in voice room X" state. Owned by `voice-session` domain.
- **Voice connection** — low-level transport/peer concerns: speaking detection, per-peer gain, mute / deafen state. Owned by `voice-connection` domain.
- **Direct call** — 1:1 voice/video call with an optional group-upgrade path. Owned by `direct-call` domain.
- **Initiator** — the peer responsible for sending the first `offer`. Elected first-peer-wins; non-initiators wait `NON_INITIATOR_GIVE_UP_MS` (≈5 s) before generating their own offer.
- **Data channel** — `chat`-labelled data channel established alongside each peer connection for P2P chat fallback and direct-message delivery.
- **Noise suppressor worklet** — RNNoise WASM running in an `AudioWorkletNode` (`NoiseSuppressorWorklet`), loaded from `rnnoise-worklet.js` at the app root.
---
## Signaling envelopes (consumed)
Defined in [websocket-envelopes](./websocket-envelopes.md). Voice-relevant types:
- `offer`, `answer`, `ice_candidate` — forwarded by the server to `targetUserId` without inspection.
- `direct-call` — forwarded; payload carries call-scoped events (ring, participant join/leave, call end).
- `voice_state` — broadcast to a server. Payload includes `roomId`, `voiceGateway`, mute/deafen flags.
- `server_users` — full peer roster on join; seeds the initial offer fan-out.
- `user_joined` — schedules a fallback offer after a grace delay (`USER_JOINED_FALLBACK_OFFER_DELAY_MS`, ≈1 s).
- `user_left` — peer teardown, with special handling that preserves peers still under an active voice session.
- `connected` / `access_denied` — connection lifecycle (server bootstrap and authorization).
The server is **purely signaling**: it does not track which `oderId` is in which voice room. Voice membership is derived client-side from the `voice_state` broadcasts observed on the server.
---
## Session establishment flow
A new participant joining a voice room produces this exchange (initiator perspective; symmetrical when both arrive at once):
1. Local user clicks "Join voice" → `VoiceSessionFacade.startSession()` populates the session model and asks `voice-connection` to ready peer transport.
2. Server broadcasts `user_joined` to existing peers.
3. Each existing peer evaluates: am I the elected initiator for the (me, new-peer) pair? If yes, the peer-connection manager calls `doCreateAndSendOffer()`.
4. Initiator constructs `new RTCPeerConnection({ iceServers })` (`infrastructure/realtime/peer-connection-manager/.../create-peer-connection.ts`), adds local tracks, creates the data channel `chat`, generates an SDP offer, and sends it via the signaling transport.
5. Responder receives `offer``doHandleOffer()` sets remote description, generates SDP answer, sends `answer`.
6. Initiator receives `answer``doHandleAnswer()` sets remote description.
7. Both sides emit `ice_candidate` as they gather candidates via `onicecandidate`.
8. `iceConnectionState` reaches `connected` / `completed` → media flows.
9. Either side may open the `chat` data channel for P2P text payloads (direct messages, etc.).
If the elected initiator never sends an offer within `NON_INITIATOR_GIVE_UP_MS`, the non-initiator promotes itself and initiates instead — preserves liveness across asymmetric drop-outs.
`user_left` is treated carefully: the `signaling-message-handler.spec.ts` covers the case where a peer is still required by an active voice session and must not be torn down, even if other parts of the system think the peer has disconnected.
---
## Domain responsibilities
### `voice-session` (`toju-app/src/app/domains/voice-session/`)
- `VoiceSessionFacade` (`application/facades/voice-session.facade.ts`) — owns the active session metadata (`serverId`, `roomId`, `participantIds`); drives a `showFloatingControls` signal when the user navigates away from the room.
- `VoiceWorkspaceService` (`application/services/voice-workspace.service.ts`) — UI state for the workspace (hidden / expanded / minimized), focused stream ID, mini-window position.
- `voiceSettingsStorage` (`infrastructure/util/voice-settings-storage.util.ts`) — localStorage persistence: input/output device IDs, output volume (0100), bitrate (32256 kbps), latency profile (`low | balanced | high`), noise-reduction toggle.
- Joining a new voice target first calls `endSession()` so transitions cannot leak peer connections.
### `voice-connection` (`toju-app/src/app/domains/voice-connection/`)
Bridges the application layer to the low-level WebRTC infrastructure under `toju-app/src/app/infrastructure/realtime/`.
- **`VoiceActivityService`** — RMS-based speaking detection via `AnalyserNode` (fftSize 256, RMS ≥ 0.015, 8-frame grace period).
- **`VoicePlaybackService`** — per-peer `GainNode` chains (0200% range), localStorage-persisted; deafen sets all gains to 0.
- **`VoiceConnectionFacade`** — exposes signals like `isVoiceConnected`, `isMuted`; methods like `toggleMute()`, `toggleNoiseReduction()`, `setOutputVolume()`.
Per the domain README, voice-connection does **not** own RTCPeerConnection construction or signaling — those live in `infrastructure/realtime/peer-connection-manager`.
### `direct-call` (`toju-app/src/app/domains/direct-call/`)
- Initiator flow (`DirectCallService.startCall()`): create/reuse the 1:1 DM, start a call-scoped voice session, send a `direct-call` "ring" envelope via `PeerDeliveryService`.
- Recipient flow: store incoming session, ring `assets/audio/call.wav` (unless DND), show in-app modal + desktop notification.
- Group upgrade: adding a third participant spawns a new group conversation; the active call swaps its chat panel to the new conversation but original DM history is preserved.
- Invariant: incoming `direct-call` events are ignored unless the local user is in `participantIds`.
### Screen share (`toju-app/src/app/domains/screen-share/`)
- Adds dedicated `MediaStreamTrack` senders to the existing peer connection (does not open a new one).
- Request / response model: a receiver sends `screen-share-request`; the sender attaches the share track; `screen-share-stop` tears it down.
- Quality presets: `low` / `balanced` / `high` (resolution + FPS).
- On Electron, `ScreenShareSourcePickerService` drives a Promise-based picker over `getSources` (see [ipc-bridge](./ipc-bridge.md)).
---
## RNNoise pipeline
Manager: `infrastructure/realtime/media/noise-reduction.manager.ts`.
```
Raw mic → MediaStreamAudioSourceNode → NoiseSuppressorWorklet (AudioWorkletNode) → MediaStreamAudioDestinationNode → clean stream → RTCPeerConnection sender
```
- AudioContext at 48 kHz.
- Worklet loaded from `rnnoise-worklet.js` (built from `@timephy/rnnoise-wasm`, output written to `toju-app/public/`).
- If worklet load fails, the raw stream is passed through unchanged.
- Mute takes priority — when muted, noise reduction is also disabled.
## Technical implementation
- **Envelope types**: see [websocket-envelopes](./websocket-envelopes.md).
- **Signaling adapter (renderer)**: `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts` (and `signaling-transport-handler.ts`).
- **Peer-connection manager**: `toju-app/src/app/infrastructure/realtime/peer-connection-manager/``create-peer-connection.ts`, recovery (grace timers, reconnect), data-channel plumbing.
- **Voice settings**: `domains/voice-session/infrastructure/util/voice-settings-storage.util.ts`.
- **Noise reduction**: `infrastructure/realtime/media/noise-reduction.manager.ts`.
- **Worklet asset**: `toju-app/public/rnnoise-worklet.js`.
- **Server side**: signaling only — `server/src/websocket/handler.ts::forwardRtcMessage`.
## Invariants
- The server forwards `offer` / `answer` / `ice_candidate` / `direct-call` envelopes opaquely and never persists media or call state.
- Switching voice rooms always tears down the prior session before starting the new one.
- Mute overrides noise reduction (the manager disables the worklet path when muted).
- Direct-call events with the local user absent from `participantIds` are ignored.
## Testing
- `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.spec.ts``user_left` peer preservation under active voice.
- `toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.spec.ts` — reconnect, grace timers, exponential backoff.
- `toju-app/src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.spec.ts`.
- `toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts`.
- E2E: `e2e/tests/voice/multi-signal-eight-user-voice.spec.ts`, `e2e/tests/voice/direct-call.spec.ts` (verify exact filenames in the suite — TODO).
## Security considerations
- WebRTC bypasses the server entirely once connected — peer IPs may be exposed to other participants via ICE candidates. Standard WebRTC privacy caveat.
- Signaling envelopes are forwarded without verifying that source and target share a server — TODO: confirm whether `forwardRtcMessage` enforces membership.
- The data channel `chat` carries P2P text payloads; integrity / authentication of those payloads is owned by the chat/direct-message domains, not by this area.
- RNNoise runs entirely client-side; mic audio never leaves the local AudioContext until it enters the encrypted RTCPeerConnection.
## Performance considerations
- Mesh topology — N×(N-1)/2 peer connections per voice room. Practical ceiling is bound by client CPU and uplink; no documented soft cap.
- Bitrate is client-controlled (32256 kbps); no server-enforced QoS.
- Voice activity detection runs at fftSize 256 with an 8-frame grace period — chosen to minimise CPU while staying responsive to natural speech.
- The signaling server's only cost is envelope forwarding (O(1) per envelope).
## Known issues and limitations
- **No SFU / MCU.** Large rooms scale linearly with participant count on each client.
- **No recording or server-side mixing** for voice or screen.
- **Bitrate is not enforced server-side** — adversarial clients could ignore the suggested range.
- **No documented call-quality telemetry pipeline.**
## Related features
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire types this area consumes.
- **[ipc-bridge](./ipc-bridge.md)** — `getSources` and the Linux audio-routing methods are used by screen-share.
- **[plugin-system](./plugin-system.md)** — plugins may participate as observers via `voice_state` broadcasts (subject to capability grants); no direct call control surface today.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

View File

@@ -0,0 +1,164 @@
# WebSocket Envelopes
> **Area:** websocket-envelopes
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
The WebSocket envelope contract is the realtime wire-format boundary between the signaling server and every connected client. Every realtime concern in Toju — presence, chat broadcasts, typing indicators, voice state, WebRTC offer/answer/ICE forwarding, direct messages, server icon P2P sync, and plugin events — travels as a typed envelope over a single WebSocket connection per client. Drift between the server definition and the client-side mirror is treated as a wire-protocol break.
## Responsibilities
- Define the canonical shape of every realtime message exchanged between `toju-app` (renderer) and `server`.
- Route incoming envelopes to a single dedicated handler on the server.
- Provide a stable identity for the connection (`connectionId`, `oderId`, `connectionScope`) and a lazy authorization model on `join_server`.
- Forward peer-targeted envelopes (WebRTC signaling, direct messages, server-icon peer transfers) without inspecting their payload.
This area does **not** own:
- The HTTP/REST surface (see [server-directory](./server-directory.md)).
- WebRTC media transport or session orchestration (see [voice-signaling](./voice-signaling.md) — the envelope contract is shared, but session lifecycle lives there).
- Persistence (server entities are owned by the server subdomain; the envelope is the contract, not the entity).
## Key concepts
- **Envelope** — a `{ type, ...payload }` message routed by `type`. Defined in `server/src/websocket/types.ts`.
- **ConnectedUser** — server-side state record per WebSocket: `connectionId`, `oderId`, `connectionScope`, `displayName`, `description`, `status`, `serverIds`, `lastPong`.
- **`oderId`** — opaque user identity. Set by the client in `identify`; falls back to a UUID if absent. Multiple connections may share an `oderId` (e.g. multiple devices) — broadcasts are deduplicated per `oderId`.
- **`connectionScope`** — typically the signal URL; disambiguates several connections from the same `oderId`.
- **Handler** — server-side function mapped to one envelope `type` in `server/src/websocket/handler.ts`.
- **Forwarded envelope** — peer-to-peer envelopes the server relays untouched to a specific `targetUserId` (offer / answer / ice_candidate / direct-call / direct-message family / server_icon_peer_*).
---
## Envelope catalogue
Defined on the server in `server/src/websocket/types.ts` and dispatched by the switch in `server/src/websocket/handler.ts`. Groups below match the dispatch shape, not a literal grouping in code.
### Connection & presence
- `identify` — client → server. Profile + `connectionScope`. Required before any other envelope is meaningful.
- `connected` — server → client. Sent automatically on connect: `{ connectionId, serverTime }`.
- `keepalive` — client ↔ server. Resets `lastPong`. See lifecycle below.
- `status_update` — broadcast presence: `online | away | busy | offline`.
- `access_denied` — server → client when `join_server` authorization fails.
### Server membership
- `join_server` — client requests membership for a `serverId`. Authorization checked via `authorizeWebSocketJoin` (`server/src/services/server-access.service.ts`). Response includes `server_users` + `plugin_requirements`.
- `view_server` — client marks a server as viewed (fetch roster + plugin requirements without joining).
- `leave_server` — client leaves; broadcasts `user_left` to remaining members.
- `server_users` — server → client. Full peer roster for a joined server (used as the seed for P2P offers).
- `user_joined` / `user_left` — broadcast presence changes.
### Chat & typing
- `chat_message` — broadcast to a server. Payload: `{ message, senderId, senderName, timestamp }`.
- `typing` — broadcast: `{ isTyping, channelId, oderId, displayName }`.
### Voice presence
- `voice_state` — broadcast user voice state (mute/deafen/room metadata). Pure signaling — the server does not store voice room membership.
### WebRTC signaling (forwarded)
- `offer` / `answer` / `ice_candidate` — forwarded to `targetUserId` via `forwardRtcMessage()`.
- `direct-call` — forwarded; semantic call lifecycle lives in the `direct-call` product-client domain.
### Direct messages (forwarded)
- `direct-message`, `direct-message-status`, `direct-message-mutation`, `direct-message-sync`, `direct-message-sync-request` — forwarded to `targetUserId`.
### Server icon P2P sync
- `server_icon_available` — client announces it has an icon at version `iconUpdatedAt`.
- `server_icon_sync_request` — client asks the server which peers have a newer icon.
- `server_icon_sync_peers` — server → client. Peer list offering newer icons.
- `server_icon_peer_request` / `server_icon_peer_data` — P2P transfer, forwarded.
### Plugins
- `plugin_event` — validated against the plugin's registered event schema (see [plugin-system](./plugin-system.md) and `server/src/services/plugin-support.service.ts`), then broadcast within the server scope. Payload: `{ serverId, pluginId, eventName, payload, sourcePluginUserId, sourceUserId, emittedAt }`.
---
## Connection lifecycle
Implemented in `server/src/websocket/index.ts`.
1. Client opens WebSocket → server generates `connectionId` (UUID), creates the `ConnectedUser` record, sends `{ type: 'connected', connectionId, serverTime }`.
2. Client sends `identify` with `oderId`, `displayName`, `connectionScope`, optional `description` / `profileUpdatedAt`. Server normalizes and stores.
3. Client sends `join_server` (or `view_server`) per server they care about. Each `join_server` is authorized independently.
4. Heartbeat: server pings every **30 s** (`PING_INTERVAL_MS`). Any incoming message also refreshes `lastPong`. Connections without a pong for **45 s** (`PONG_TIMEOUT_MS`) are terminated.
5. On close: server emits `user_left` to every server the connection had joined. Broadcasts are **deduplicated by `oderId`**, so multi-device users only generate one departure event per logical identity.
---
## Authentication model
There is no bearer token or signed envelope. Identity is whatever the client claims in `identify`. Authorization is **per-`join_server`**, evaluated by `authorizeWebSocketJoin` against persisted server access rules (private flag, password hash, bans, invite/join-request state). `access_denied` is returned when authorization fails; the connection itself stays open.
---
## Technical implementation
### Server
- **Types**: `server/src/websocket/types.ts``WsMessage` (union over `type`), `ConnectedUser`, `ConnectionScope`.
- **Dispatcher**: `server/src/websocket/handler.ts``handleWebSocketMessage(connectionId, message)`. Single switch (~16 dedicated handler functions plus `forwardRtcMessage`).
- **Lifecycle**: `server/src/websocket/index.ts``ws` server, ping/pong, connection registry, dead-connection reaping.
- **Plugin event validation**: `server/src/services/plugin-support.service.ts` — async `validatePluginEventEnvelope()` (runtime schema check).
### Client (renderer)
- **Shared types**: `toju-app/src/app/shared-kernel/signaling-contracts.ts`**stale**, only declares a generic `SignalingMessage` and an obsolete `SignalingMessageType` enum. Not the active wire-format definition.
- **Active envelope shapes** are defined inline as `IncomingSignalingMessage` in `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts`.
- **Constants**: `toju-app/src/app/infrastructure/realtime/realtime.constants.ts` — every envelope `type` string lives here as `SIGNALING_TYPE_*`.
- **Transport**: `toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts` — socket lifecycle, sends `identify`, `join_server`, raw envelopes.
- **Coordinator**: `toju-app/src/app/infrastructure/realtime/signaling/server-signaling-coordinator.ts` — maps `serverId` to signal URL (Toju supports multiple federated signaling endpoints).
- **Inbound dispatch**: `signaling-message-handler.ts``handleConnectedSignalingMessage`, `handleServerUsersSignalingMessage`, `handleUserJoinedSignalingMessage`, `handleUserLeftSignalingMessage`, `handleOfferSignalingMessage`, `handleAnswerSignalingMessage`, `handleIceCandidateSignalingMessage`, `handleAccessDeniedSignalingMessage`. Domain envelopes (chat/typing/direct-message/etc.) are consumed in the respective product-client domains, not in this central adapter — TODO: enumerate exact subscription points.
### Versioning
No `version` field on envelopes. No `Accept-Version` header. Drift between server and client is enforced only by code review (per `server/CONTEXT.md` invariants).
---
## Testing
- `server/src/websocket/handler-status.spec.ts``status_update` broadcast and profile metadata in `user_joined` / `server_users`.
- `server/src/websocket/handler-plugin.spec.ts``plugin_event` validation and broadcast.
- `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.spec.ts` — inbound handler unit tests (notably `user_left` preserving peers under voice).
- **TODO**: no round-trip envelope-shape test between server `WsMessage` and client `IncomingSignalingMessage`. Drift can only be caught by E2E or manual review today.
## Security considerations
- No transport-level auth — identity is self-asserted via `identify`. The server trusts `oderId` for routing but checks authorization on every `join_server`.
- WebRTC signaling envelopes (`offer` / `answer` / `ice_candidate`) are forwarded **without inspection**. The server does not verify that the sender is a member of the same server as the target — TODO: confirm whether `forwardRtcMessage` enforces server-membership before forwarding.
- `plugin_event` payloads are bounded by the plugin's declared `maxPayloadBytes` (default 64 KB) and validated against the plugin's declared event schema. See [plugin-system](./plugin-system.md).
- Multi-connection identities: a single `oderId` may have many open sockets. Broadcasts dedupe by `oderId`, but per-connection state (e.g. `voice_state`) does not — TODO: document the cross-connection invariants.
## Performance considerations
- Single WebSocket per client. No fan-out worker; broadcast is in-process via the in-memory connection map.
- Ping cadence 30 s / pong timeout 45 s. Reaping is per-connection on next tick.
- TODO: no documented soft cap on connected users per signaling server.
## Known issues and limitations
- **Stale shared-kernel contract.** `toju-app/src/app/shared-kernel/signaling-contracts.ts` does not enumerate the live envelope set; client code uses `IncomingSignalingMessage` in `signaling-message-handler.ts` instead. Update or replace this file when adjacent work touches the wire format.
- **No envelope versioning.** Any field rename is an immediate break for older clients.
- **TODO — operator concerns**: rate limits, max-message-size, and backpressure are not documented.
## Related features
- **[voice-signaling](./voice-signaling.md)** — consumes `offer` / `answer` / `ice_candidate` / `voice_state` / `direct-call`.
- **[plugin-system](./plugin-system.md)** — defines and validates `plugin_event`.
- **[server-directory](./server-directory.md)** — REST counterpart for server discovery, joining, and moderation; `join_server` envelope authorization reuses the same access rules.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

44
docs-site/CONTEXT.md Normal file
View File

@@ -0,0 +1,44 @@
# Application Documentation (docs-site)
Owns the Docusaurus-based application and plugin-author documentation. The build output (`docs-site/build/`) is bundled into the Electron app and served by the Local API server at runtime, so documentation is available offline inside the desktop client.
> **Format reference:**
> - **Vocabulary** — bold term, one-sentence definition, aliases to avoid.
> - **Relationships** — bullets with bold terms and cardinality.
> - **Boundaries / IO** — what this subdomain exposes and consumes.
> - **Invariants** — rules that always hold.
> - **Flagged ambiguities** — terms in dispute with proposed resolutions.
>
> See `agents-docs/AGENTS_CONTEXT.md` for the contract. Update in the same turn a trigger fires (see `agents-docs/AGENT_WORKFLOW.md` § CONTEXT.md upkeep).
## Vocabulary
| Term | Definition | Aliases to avoid |
|------|------------|------------------|
| **App docs** | End-user-facing documentation for the MetoYou desktop client. | "manual" |
| **Plugin docs** | Developer-facing reference for the plugin runtime — manifest format, lifecycle hooks, host APIs. Authoritative source for the plugin contract surface. | "API docs" |
| **Local API server** | The Electron in-process HTTP server that mounts `docs-site/build/` so the renderer can browse docs offline. Defined under `electron/api/`. | "embedded server" |
## Relationships
- **Plugin docs** describe contracts implemented in `toju-app/src/app/shared-kernel/plugin-system.contracts.ts` (renderer side) and `electron/plugin-library.ts` (host side) — keep them in lockstep with code changes.
- The **build output** at `docs-site/build/` is a deploy artifact for the **electron** Local API server; `npm run build:all` requires `npm run build:docs` to have run.
- The site is also deployed publicly via `.gitea/workflows/deploy-web-apps.yml` for browsing outside the app.
## Boundaries / IO
- **Exposes:** static Docusaurus bundle at `docs-site/build/`, mounted by Electron's Local API server and also deployed as a public static site.
- **Consumes:** Markdown sources under `docs-site/docs/`, plus any code-derived references (e.g. OpenAPI documents from `electron/api/openapi.ts`).
## Invariants
- Plugin-contract documentation must match the code; if the manifest schema or lifecycle changes, **plugin docs** and `agents-docs/features/<plugin-doc>.md` both update in the same task.
- Build artifacts (`docs-site/build/`) are generated, not committed.
## Flagged ambiguities
- _None recorded yet._
---
*Agent-procedural rules (TDD, lint, build) live in `/AGENTS.md`. This file is the bounded-context domain artefact for the documentation site.*

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

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

50
e2e/CONTEXT.md Normal file
View File

@@ -0,0 +1,50 @@
# End-to-End Suite (e2e)
Owns Playwright-based end-to-end verification of the desktop product. Tests boot the real Electron application against the real signaling server and exercise user-visible flows across chat, voice, screen-share, settings, plugins, and auth.
> **Format reference:**
> - **Vocabulary** — bold term, one-sentence definition, aliases to avoid.
> - **Relationships** — bullets with bold terms and cardinality.
> - **Boundaries / IO** — what this subdomain exposes and consumes.
> - **Invariants** — rules that always hold.
> - **Flagged ambiguities** — terms in dispute with proposed resolutions.
>
> See `agents-docs/AGENTS_CONTEXT.md` for the contract. Update in the same turn a trigger fires (see `agents-docs/AGENT_WORKFLOW.md` § CONTEXT.md upkeep).
## Vocabulary
| Term | Definition | Aliases to avoid |
|------|------------|------------------|
| **Feature area** | A top-level folder under `tests/` (`auth/`, `chat/`, `voice/`, `screen-share/`, `settings/`, `plugins/`) corresponding to a slice of user-visible behavior. | "category", "section" |
| **Page object** | A test-side abstraction over a screen or panel that exposes user-intent methods rather than raw selectors. | "page model" |
| **Fixture** | A Playwright `test.extend(...)` setup that prepares one or more user/app instances before a test runs. | "helper" |
| **Pair test** | An E2E test that boots two Electron instances simultaneously to verify P2P flows (calls, screen-share, transfers). | "multi-client test" |
## Relationships
- A **Feature area** owns one or more `*.spec.ts` files plus its own **Page objects** and **Fixtures**.
- A **Pair test** depends on the **server** subdomain being reachable — both clients connect to the same signaling server.
- **Page objects** depend only on the rendered DOM produced by **toju-app**; if a selector changes, only the page object should need updating.
- The suite as a whole depends on **electron** (built via `npm run build:electron`) and a usable **server** (`npm run server:dev` or `npm run dev`).
## Boundaries / IO
- **Exposes:** test results (HTML report at `test-results/html-report`, JUnit/JSON output on CI). No production surface.
- **Consumes:**
- The built product (toju-app + electron) — typically launched via Playwright's Electron support.
- The signaling server (started before the suite runs).
- System resources: audio devices for voice tests, screen-capture for screen-share tests. The `.agents/skills/playwright-e2e/SKILL.md` documents how the suite handles the multi-client setup.
## Invariants
- Tests interact only through **Page objects** and **Fixtures** — no raw `page.click('.css-class-name')` scattered across specs.
- Tests must clean up state between runs — a flaky run that leaves cruft in the local database or signaling server is a bug, not an environment issue.
- The suite must run headless on CI (`npm run test:e2e`); the `ui` and `debug` variants exist for local development only.
## Flagged ambiguities
- _None recorded yet — add entries when a test concept (e.g. "pair test" vs "multi-client test") resists clean definition._
---
*Agent-procedural rules (TDD, lint) live in `/AGENTS.md`. Practical patterns for writing Playwright tests on this project live in `.agents/skills/playwright-e2e/SKILL.md`. This file is the bounded-context domain artefact for the E2E suite.*

36
e2e/README.md Normal file
View File

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

View File

@@ -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);
});
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,38 +23,103 @@ 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)
? endpointsOrPort.map((endpoint) => ({
id: endpoint.id,
name: endpoint.name,
url: endpoint.url,
isActive: endpoint.isActive ?? true,
isDefault: endpoint.isDefault ?? false,
status: endpoint.status ?? 'unknown'
}))
: [
{
id: 'e2e-test-server', id: 'e2e-test-server',
name: 'E2E Test Server', name: 'E2E Test Server',
url: `http://localhost:${port}`, url: `http://localhost:${endpointsOrPort}`,
isActive: true, isActive: true,
isDefault: false, isDefault: false,
status: 'unknown' 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
}; };
} }
function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void { function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void {
try { try {
const storage = window.localStorage; const storage = window.localStorage;
const currentUserId = storage.getItem('metoyou_currentUserId')?.trim() || null;
const generalSettings = JSON.stringify({
reopenLastViewedChat: false
});
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints)); storage.setItem(storageState.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'
]));
storage.setItem('metoyou_general_settings', generalSettings);
if (currentUserId) {
storage.setItem(`metoyou_general_settings__${encodeURIComponent(currentUserId)}`, generalSettings);
}
const keysToRemove: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key === 'metoyou_lastViewedChat' || key?.startsWith('metoyou_lastViewedChat__')) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
storage.removeItem(key);
}
} catch { } catch {
// about:blank and some Playwright UI pages deny localStorage access. // about:blank and some Playwright UI pages deny localStorage access.
} }
} }
export async function disableLastViewedChatResume(page: Page): Promise<void> {
await page.evaluate(() => {
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim() || null;
const generalSettings = JSON.stringify({ reopenLastViewedChat: false });
const keysToRemove: string[] = [];
localStorage.setItem('metoyou_general_settings', generalSettings);
if (currentUserId) {
localStorage.setItem(`metoyou_general_settings__${encodeURIComponent(currentUserId)}`, generalSettings);
}
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key === 'metoyou_lastViewedChat' || key?.startsWith('metoyou_lastViewedChat__')) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
localStorage.removeItem(key);
}
});
}
export async function installTestServerEndpoint( export async function installTestServerEndpoint(
context: BrowserContext, context: BrowserContext,
port: number = Number(process.env.TEST_SERVER_PORT) || 3099 port: number = Number(process.env.TEST_SERVER_PORT) || 3099
@@ -55,11 +129,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 +158,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);
}

View File

@@ -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
View File

@@ -0,0 +1,132 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import { createServer } from 'node:net';
import { join } from 'node:path';
export interface TestServerHandle {
port: number;
url: string;
stop: () => Promise<void>;
}
const E2E_DIR = join(__dirname, '..');
const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
export async function startTestServer(retries = 3): Promise<TestServerHandle> {
for (let attempt = 1; attempt <= retries; attempt++) {
const port = await allocatePort();
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
cwd: E2E_DIR,
env: {
...process.env,
TEST_SERVER_PORT: String(port)
},
stdio: 'pipe'
});
child.stdout?.on('data', (chunk: Buffer | string) => {
process.stdout.write(chunk.toString());
});
child.stderr?.on('data', (chunk: Buffer | string) => {
process.stderr.write(chunk.toString());
});
try {
await waitForServerReady(port, child);
} catch (error) {
await stopServer(child);
if (attempt < retries) {
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
continue;
}
throw error;
}
return {
port,
url: `http://localhost:${port}`,
stop: async () => {
await stopServer(child);
}
};
}
throw new Error('startTestServer: unreachable');
}
async function allocatePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const probe = createServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
if (!address || typeof address === 'string') {
probe.close();
reject(new Error('Failed to resolve an ephemeral test server port'));
return;
}
const { port } = address;
probe.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
async function waitForServerReady(port: number, child: ChildProcess, timeoutMs = 30_000): Promise<void> {
const readyUrl = `http://127.0.0.1:${port}/api/servers?limit=1`;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`Test server exited before becoming ready (exit code ${child.exitCode})`);
}
try {
const response = await fetch(readyUrl);
if (response.ok) {
return;
}
} catch {
// Server still starting.
}
await wait(250);
}
throw new Error(`Timed out waiting for test server on port ${port}`);
}
async function stopServer(child: ChildProcess): Promise<void> {
if (child.exitCode !== null) {
return;
}
child.kill('SIGTERM');
const exited = await Promise.race([once(child, 'exit').then(() => true), wait(3_000).then(() => false)]);
if (!exited && child.exitCode === null) {
child.kill('SIGKILL');
await once(child, 'exit');
}
}
function wait(durationMs: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, durationMs);
});
}

View File

@@ -11,21 +11,48 @@ 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 dataChannels: RTCDataChannel[] = [];
const syntheticMediaResources: {
audioCtx: AudioContext;
source?: AudioScheduledSourceNode;
drawIntervalId?: number;
}[] = [];
(window as any).__rtcConnections = connections; (window as any).__rtcConnections = connections;
(window as any).__rtcDataChannels = dataChannels;
(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;
const trackDataChannel = (channel: RTCDataChannel) => {
if (dataChannels.includes(channel)) {
return;
}
dataChannels.push(channel);
};
(window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) { (window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) {
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args); const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
const originalCreateDataChannel = pc.createDataChannel.bind(pc);
connections.push(pc); connections.push(pc);
pc.createDataChannel = ((label: string, options?: RTCDataChannelInit) => {
const channel = originalCreateDataChannel(label, options);
trackDataChannel(channel);
return channel;
}) as RTCPeerConnection['createDataChannel'];
pc.addEventListener('connectionstatechange', () => { pc.addEventListener('connectionstatechange', () => {
(window as any).__lastRtcState = pc.connectionState; (window as any).__lastRtcState = pc.connectionState;
}); });
pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => {
trackDataChannel(event.channel);
});
pc.addEventListener('track', (event: RTCTrackEvent) => { pc.addEventListener('track', (event: RTCTrackEvent) => {
(window as any).__rtcRemoteTracks.push({ (window as any).__rtcRemoteTracks.push({
kind: event.track.kind, kind: event.track.kind,
@@ -40,48 +67,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 +113,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 +150,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 +212,237 @@ 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 }
);
}
/** Returns the number of tracked RTCDataChannels in the open state. */
export async function getOpenDataChannelCount(page: Page): Promise<number> {
return page.evaluate(
() => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
(channel) => channel.readyState === 'open'
).length ?? 0
);
}
/** Wait until the expected number of tracked RTCDataChannels are open. */
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
await page.waitForFunction(
(count) => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
(channel) => channel.readyState === 'open'
).length === count,
expectedCount,
{ timeout }
);
}
/** Close every currently-open RTCDataChannel and return how many were closed. */
export async function closeOpenDataChannels(page: Page): Promise<number> {
return page.evaluate(() => {
const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
let closed = 0;
for (const channel of channels) {
if (channel.readyState !== 'open') {
continue;
}
channel.close();
closed++;
}
return closed;
});
}
/** Dispatch a synthetic data-channel error event on each open channel. */
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
return page.evaluate(() => {
const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
let dispatched = 0;
for (const channel of channels) {
if (channel.readyState !== 'open') {
continue;
}
channel.dispatchEvent(new Event('error'));
dispatched++;
}
return dispatched;
});
}
/**
* 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

View File

@@ -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> {

View File

@@ -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, '\\$&');
}

View File

@@ -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,15 @@ 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() {

View File

@@ -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();
} }
} }

View File

@@ -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();
} }
@@ -59,7 +79,19 @@ export class ServerSearchPage {
await this.page.getByRole('button', { name }).click(); await this.page.getByRole('button', { name }).click();
} }
async joinServerFromSearch(name: string) { async joinServerFromSearch(name: string, options: { acceptPluginDownloads?: boolean } = {}) {
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();
if (options.acceptPluginDownloads) {
const pluginConsentDialog = this.page.getByRole('dialog', { name: /uses plugins/ });
await expect(pluginConsentDialog).toBeVisible({ timeout: 20_000 });
await pluginConsentDialog.getByRole('button', { name: 'Accept and join' }).click();
}
} }
} }

View File

@@ -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
}, }
}); });

View File

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

View File

@@ -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,15 +213,52 @@ 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');
const serverName = `Chat Server ${suffix}`; const serverName = `Chat Server ${suffix}`;
@@ -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)}`;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,11 +76,11 @@ async function joinVoiceTogether(
await expect(existingChannel).toBeVisible({ timeout: 10_000 }); await expect(existingChannel).toBeVisible({ timeout: 10_000 });
} }
const bobRoom = new ChatRoomPage(bob.page);
const doJoin = async () => {
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL); await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
const bobRoom = new ChatRoomPage(bob.page);
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 });
@@ -93,6 +89,32 @@ async function joinVoiceTogether(
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()));

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,181 @@
import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import {
closeOpenDataChannels,
dispatchDataChannelErrors,
dumpRtcDiagnostics,
getOpenDataChannelCount,
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForOpenDataChannelCount
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
interface VoiceClient extends Client {
displayName: string;
username: string;
}
const USER_PASSWORD = 'TestPass123!';
const VOICE_CHANNEL = 'General';
test.describe('Voice data-channel recovery', () => {
test('keeps two users hearing each other after a data-channel error and close', async ({ createClient }) => {
test.setTimeout(240_000);
const clients = await createVoiceScenario(createClient, 2, `DC Recovery Duo ${Date.now()}`);
const [alice, bob] = clients;
await assertMeshAudio(clients, 1, 'initial two-user voice');
await test.step('A non-fatal data-channel error does not interrupt audio', async () => {
const dispatched = await dispatchDataChannelErrors(alice.page);
expect(dispatched).toBeGreaterThan(0);
await waitForOpenDataChannelCount(alice.page, 1, 15_000);
await waitForOpenDataChannelCount(bob.page, 1, 15_000);
await assertMeshAudio(clients, 1, 'after synthetic data-channel error');
});
await test.step('A closed data channel is rebuilt and audio resumes both ways', async () => {
const closed = await closeOpenDataChannels(alice.page);
expect(closed).toBeGreaterThan(0);
await waitForConnectedPeerCount(alice.page, 1, 60_000);
await waitForConnectedPeerCount(bob.page, 1, 60_000);
await waitForOpenDataChannelCount(alice.page, 1, 60_000);
await waitForOpenDataChannelCount(bob.page, 1, 60_000);
await assertMeshAudio(clients, 1, 'after data-channel close recovery');
});
});
test('heals a three-user voice mesh when one client loses every data channel', async ({ createClient }) => {
test.setTimeout(300_000);
const clients = await createVoiceScenario(createClient, 3, `DC Recovery Trio ${Date.now()}`);
const bob = clients[1];
await assertMeshAudio(clients, 2, 'initial three-user mesh');
await test.step('Bob loses all control channels and the full mesh recovers', async () => {
const closed = await closeOpenDataChannels(bob.page);
expect(closed).toBe(2);
for (const client of clients) {
await waitForConnectedPeerCount(client.page, 2, 90_000);
await waitForOpenDataChannelCount(client.page, 2, 90_000);
}
await assertMeshAudio(clients, 2, 'after full control-channel recovery');
});
});
});
async function createVoiceScenario(
createClient: () => Promise<Client>,
userCount: number,
serverName: string
): Promise<VoiceClient[]> {
const clients: VoiceClient[] = [];
for (let index = 0; index < userCount; index++) {
const client = await createClient();
const displayName = `DC Voice ${index + 1}`;
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
await installAutoResumeAudioContext(client.page);
clients.push({
...client,
displayName,
username: `dc_voice_${Date.now()}_${index + 1}`
});
}
await test.step('Register clients', async () => {
for (const client of clients) {
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(client.username, client.displayName, USER_PASSWORD);
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
}
});
await test.step('Create and join server', async () => {
const hostSearch = new ServerSearchPage(clients[0].page);
await hostSearch.createServer(serverName, { description: 'Data-channel recovery voice test' });
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
for (const client of clients.slice(1)) {
const searchPage = new ServerSearchPage(client.page);
await searchPage.joinServerFromSearch(serverName);
await expect(client.page).toHaveURL(/\/room\//, { timeout: 20_000 });
}
});
await test.step('Join everyone to voice', async () => {
const hostRoom = new ChatRoomPage(clients[0].page);
await hostRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.joinVoiceChannel(VOICE_CHANNEL);
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
}
const expectedRemotePeers = clients.length - 1;
for (const client of clients) {
await waitForConnectedPeerCount(client.page, expectedRemotePeers, 90_000);
await waitForOpenDataChannelCount(client.page, expectedRemotePeers, 90_000);
await waitForAudioStatsPresent(client.page, 30_000);
}
});
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 assertMeshAudio(
clients: readonly VoiceClient[],
expectedRemotePeers: number,
label: string
): Promise<void> {
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, expectedRemotePeers, 60_000);
} catch (error) {
const dataChannelCount = await getOpenDataChannelCount(client.page);
console.log(`[${client.displayName} ${label} data channels] ${dataChannelCount}`);
console.log(`[${client.displayName} ${label} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
}

View File

@@ -0,0 +1,745 @@
import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import {
closeOpenDataChannels,
dumpRtcDiagnostics,
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForOpenDataChannelCount,
waitForInboundVideoFlow,
waitForOutboundVideoFlow,
waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { disableLastViewedChatResume } from '../../helpers/seed-test-endpoint';
interface DirectCallScenario {
alice: Client;
bob: Client;
charlie?: Client;
aliceUserId: string;
bobUserId: string;
charlieUserId?: string;
}
interface AudioFlowDelta {
outboundBytesDelta: number;
inboundBytesDelta: number;
outboundPacketsDelta: number;
inboundPacketsDelta: number;
}
interface VideoFlowDelta {
outboundBytesDelta: number;
inboundBytesDelta: number;
outboundPacketsDelta: number;
inboundPacketsDelta: number;
}
const USER_PASSWORD = 'TestPass123!';
test.describe('Direct private calls', () => {
test.describe.configure({ timeout: 240_000 });
test('two users can ring, answer, chat, see self voice indicators, and exchange audio', async ({ createClient }) => {
const scenario = await createDirectCallScenario(createClient, { includeCharlie: true });
const callMessage = `Call chat ${uniqueName('msg')}`;
const privateOnlyMessage = `Private before group ${uniqueName('msg')}`;
const groupMessage = `Group call chat ${uniqueName('msg')}`;
await test.step('Alice starts a call from the search people card', async () => {
await disableLastViewedChatResume(scenario.alice.page);
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
const bobPeopleCard = scenario.alice.page.locator(`[data-testid="user-card-${scenario.bobUserId}"]`, { hasText: 'Bob' }).first();
await expect(bobPeopleCard).toBeVisible({ timeout: 20_000 });
await bobPeopleCard.hover();
await bobPeopleCard.getByRole('button', { name: 'Call Bob' }).click();
await expect(scenario.alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await expect(scenario.alice.page.locator('app-private-call')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
});
await test.step('Alice starts sharing before Bob joins', async () => {
await scenario.alice.page.getByRole('button', { name: 'Share screen' }).click();
await expect(scenario.alice.page.getByRole('button', { name: 'Stop sharing screen' })).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
});
await test.step('Bob receives a ringing call and the ring stops when he answers', async () => {
await expect
.poll(async () => await getCallAudioPlayCount(scenario.bob.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(0);
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect
.poll(async () => await getCallNotificationCount(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBeGreaterThan(0);
await answerIncomingCall(scenario.bob.page);
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
await expect
.poll(async () => await getCallAudioPauseCount(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBeGreaterThan(0);
});
await test.step('WebRTC connects and late-join screen share is visible', async () => {
await waitForPeerConnected(scenario.alice.page, 45_000);
await waitForPeerConnected(scenario.bob.page, 45_000);
await expectParticipantConnected(scenario.alice.page, scenario.aliceUserId);
await expectParticipantConnected(scenario.alice.page, scenario.bobUserId);
await expectParticipantConnected(scenario.bob.page, scenario.aliceUserId);
await expectParticipantConnected(scenario.bob.page, scenario.bobUserId);
const aliceVideo = await waitForOutboundVideoFlow(scenario.alice.page, 30_000);
const bobVideo = await waitForInboundVideoFlow(scenario.bob.page, 30_000);
if (!isOutboundVideoFlowing(aliceVideo) || !isInboundVideoFlowing(bobVideo)) {
console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page)));
console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page)));
}
expectOutboundVideoFlow(aliceVideo, 'Alice late-join direct call screen share');
expectInboundVideoFlow(bobVideo, 'Bob late-join direct call screen share');
await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
});
await test.step('Audio flows in both directions', async () => {
await waitForAudioStatsPresent(scenario.alice.page, 30_000);
await waitForAudioStatsPresent(scenario.bob.page, 30_000);
const aliceDelta = await waitForAudioFlow(scenario.alice.page, 45_000);
const bobDelta = await waitForAudioFlow(scenario.bob.page, 45_000);
if (!isAudioFlowing(aliceDelta) || !isAudioFlowing(bobDelta)) {
console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page)));
console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page)));
}
expectAudioFlow(aliceDelta, 'Alice direct call');
expectAudioFlow(bobDelta, 'Bob direct call');
});
await test.step('Adding a third participant converts the call chat to an empty group chat', async () => {
if (!scenario.charlie || !scenario.charlieUserId) {
throw new Error('Expected direct-call scenario to include Charlie.');
}
const charlie = scenario.charlie;
await scenario.alice.page.getByTestId('dm-input').fill(privateOnlyMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toBeVisible({ timeout: 20_000 });
await scenario.alice.page.getByLabel('Add user to call').selectOption(scenario.charlieUserId);
await scenario.alice.page.getByRole('button', { name: 'Add user' }).click();
await expect(scenario.alice.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0, {
timeout: 20_000
});
await expect(scenario.alice.page.locator('[data-testid^="dm-rail-item-dm-group-"]')).toHaveCount(0, { timeout: 20_000 });
await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect
.poll(async () => await getCallAudioPlayCount(charlie.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(0);
await answerIncomingCall(charlie.page);
await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0);
await waitForConnectedPeerCount(scenario.alice.page, 2, 45_000);
await waitForConnectedPeerCount(scenario.bob.page, 2, 45_000);
await waitForConnectedPeerCount(charlie.page, 2, 45_000);
await Promise.all([
waitForAllPeerAudioFlow(scenario.alice.page, 2, 45_000),
waitForAllPeerAudioFlow(scenario.bob.page, 2, 45_000),
waitForAllPeerAudioFlow(charlie.page, 2, 45_000)
]);
await expectParticipantConnected(scenario.alice.page, scenario.charlieUserId);
await expectParticipantConnected(scenario.bob.page, scenario.charlieUserId);
await expectParticipantConnected(charlie.page, scenario.aliceUserId);
await expectParticipantConnected(charlie.page, scenario.bobUserId);
await expectParticipantConnected(charlie.page, scenario.charlieUserId);
await scenario.alice.page.getByTestId('dm-input').fill(groupMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 });
await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Private call streams can switch between all-stream and focused viewing', async () => {
await scenario.bob.page.getByRole('button', { name: 'Turn camera on' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Turn camera off' })).toBeVisible({ timeout: 20_000 });
await expect
.poll(async () => await privateCallGridStreamCount(scenario.bob.page), {
timeout: 30_000,
intervals: [500, 1_000]
})
.toBeGreaterThanOrEqual(2);
await scenario.bob.page
.getByTestId('private-call-stream-grid')
.locator('app-voice-workspace-stream-tile')
.first()
.click();
await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByTestId('private-call-show-all-streams')).toBeVisible({ timeout: 20_000 });
await assertSustainedMediaFlow(scenario.alice.page, scenario.bob.page, 'direct call screen share and camera');
await scenario.bob.page.getByTestId('private-call-focused-stream').dblclick();
await expect
.poll(async () => await hasFullscreenElement(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(true);
await exitFullscreen(scenario.bob.page);
await expect
.poll(async () => await hasFullscreenElement(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(false);
await scenario.bob.page.getByTestId('private-call-show-all-streams').click();
await expect(scenario.bob.page.getByTestId('private-call-stream-grid')).toBeVisible({ timeout: 20_000 });
});
await test.step('Both clients show their own speaking indicator', async () => {
await expect(scenario.alice.page.getByTestId(`call-participant-${scenario.aliceUserId}`)).toHaveClass(/ring-emerald-400/, {
timeout: 20_000
});
await expect(scenario.bob.page.getByTestId(`call-participant-${scenario.bobUserId}`)).toHaveClass(/ring-emerald-400/, {
timeout: 20_000
});
});
await test.step('Private call layout does not require vertical scrolling', async () => {
await expect
.poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.alice.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(true);
await expect
.poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(true);
await expect(scenario.alice.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0);
await expect(scenario.bob.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0);
await expect(scenario.alice.page.getByText('No live streams yet')).toHaveCount(0);
await expect(scenario.bob.page.getByText('No live streams yet')).toHaveCount(0);
const originalWidth = await privateCallChatWidth(scenario.alice.page);
const resizer = scenario.alice.page.getByTestId('private-call-chat-resizer');
const box = await resizer.boundingBox();
expect(box, 'private call chat resizer should be measurable').not.toBeNull();
if (box) {
await scenario.alice.page.mouse.move(box.x + box.width / 2, box.y + 20);
await scenario.alice.page.mouse.down();
await scenario.alice.page.mouse.move(box.x - 96, box.y + 20);
await scenario.alice.page.mouse.up();
}
await expect
.poll(async () => await privateCallChatWidth(scenario.alice.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBeGreaterThan(originalWidth + 40);
});
await test.step('Embedded call chat syncs and does not expose another call button', async () => {
await expect(scenario.alice.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0);
await expect(scenario.bob.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0);
await scenario.bob.page.getByTestId('dm-input').fill('typing from Bob');
await expect(scenario.alice.page.getByTestId('dm-typing-indicator')).toContainText('Bob is typing', { timeout: 20_000 });
await scenario.alice.page.getByTestId('dm-input').fill(callMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(callMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Group chat call button rings every other participant', async () => {
if (!scenario.charlie) {
throw new Error('Expected direct-call scenario to include Charlie.');
}
await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await scenario.charlie.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.charlie.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
const bobPlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.bob.page);
const charliePlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.charlie.page);
await scenario.alice.page
.locator('app-dm-chat header')
.getByRole('button', { name: /Call/i })
.click();
await expect(scenario.alice.page).toHaveURL(/\/call\/dm-group-/, { timeout: 20_000 });
await expect
.poll(async () => await getCallAudioPlayCount(scenario.bob.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(bobPlayCountBeforeGroupCall);
await expect
.poll(async () => await getCallAudioPlayCount(scenario.charlie.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(charliePlayCountBeforeGroupCall);
await answerIncomingCall(scenario.bob.page);
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
await answerIncomingCall(scenario.charlie.page);
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.charlie.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
});
});
test('keeps private-call audio flowing after the data channel closes', async ({ createClient }) => {
const scenario = await createDirectCallScenario(createClient);
await test.step('Alice starts a private call and Bob joins', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await answerIncomingCall(scenario.bob.page);
await waitForConnectedPeerCount(scenario.alice.page, 1, 45_000);
await waitForConnectedPeerCount(scenario.bob.page, 1, 45_000);
await waitForOpenDataChannelCount(scenario.alice.page, 1, 45_000);
await waitForOpenDataChannelCount(scenario.bob.page, 1, 45_000);
await waitForAllPeerAudioFlow(scenario.alice.page, 1, 45_000);
await waitForAllPeerAudioFlow(scenario.bob.page, 1, 45_000);
});
await test.step('Data-channel recovery keeps the call audible', async () => {
const closed = await closeOpenDataChannels(scenario.alice.page);
expect(closed).toBeGreaterThan(0);
await waitForOpenDataChannelCount(scenario.alice.page, 1, 60_000);
await waitForOpenDataChannelCount(scenario.bob.page, 1, 60_000);
await waitForAllPeerAudioFlow(scenario.alice.page, 1, 60_000);
await waitForAllPeerAudioFlow(scenario.bob.page, 1, 60_000);
});
});
test('missing and ended private calls do not leave stale call controls behind', async ({ createClient }) => {
const scenario = await createDirectCallScenario(createClient);
await test.step('Unknown call routes render an inert empty state', async () => {
await scenario.alice.page.goto('/call/not-a-real-call', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page.getByText('No active call for this route.')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByRole('button', { name: 'Join call' })).toHaveCount(0);
});
await test.step('Caller leaving before answer clears recipient call route and rail icon', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await expect(incomingCallDialog(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await expect(incomingCallDialog(scenario.bob.page)).toHaveCount(0, { timeout: 20_000 });
await expect(scenario.bob.page.getByRole('button', { name: 'Open private call' })).toHaveCount(0);
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(0);
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
});
await test.step('Leaving an answered call clears local ringing and returns to DM', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await answerIncomingCall(scenario.bob.page);
await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
});
});
});
async function createDirectCallScenario(
createClient: () => Promise<Client>,
options: { includeCharlie?: boolean } = {}
): Promise<DirectCallScenario> {
const suffix = uniqueName('direct-call');
const serverName = `Direct Call Server ${suffix}`;
const alice = await createClient();
const bob = await createClient();
const charlie = options.includeCharlie ? await createClient() : undefined;
await installDirectCallInstrumentation(alice.page);
await installDirectCallInstrumentation(bob.page);
if (charlie) {
await installDirectCallInstrumentation(charlie.page);
}
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
if (charlie) {
await registerUser(charlie.page, `charlie_${suffix}`, 'Charlie');
}
const aliceUserId = await getCurrentUserId(alice.page);
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, { description: 'E2E direct call discovery server' });
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(alice.page).waitForReady();
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(bob.page).waitForReady();
if (charlie) {
const charlieSearch = new ServerSearchPage(charlie.page);
await charlieSearch.joinServerFromSearch(serverName);
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(charlie.page).waitForReady();
}
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
const charlieRoomCard = charlie ? alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Charlie' }).first() : null;
await expect(bobRoomCard).toBeVisible({ timeout: 20_000 });
if (charlieRoomCard) {
await expect(charlieRoomCard).toBeVisible({ timeout: 20_000 });
}
const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid');
const bobUserId = bobUserCardTestId?.replace('room-user-card-', '');
const charlieUserCardTestId = charlieRoomCard ? await charlieRoomCard.getAttribute('data-testid') : null;
const charlieUserId = charlieUserCardTestId?.replace('room-user-card-', '');
if (!aliceUserId || !bobUserId || (charlie && !charlieUserId)) {
throw new Error('Expected direct-call scenario users to expose stable ids.');
}
return {
alice,
bob,
charlie,
aliceUserId,
bobUserId,
charlieUserId
};
}
async function installDirectCallInstrumentation(page: Page): Promise<void> {
await installWebRTCTracking(page);
await installAutoResumeAudioContext(page);
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
})
);
const OriginalAudio = window.Audio;
const callAudioState = {
activeLoops: 0,
pauseCount: 0,
playCount: 0
};
const callNotificationState = {
count: 0,
bodies: [] as string[],
titles: [] as string[]
};
(window as Window & { __callAudioState?: typeof callAudioState }).__callAudioState = callAudioState;
(window as Window & { __callNotificationState?: typeof callNotificationState }).__callNotificationState = callNotificationState;
function isCallAudio(audio: HTMLAudioElement): boolean {
return audio.src.includes('/assets/audio/call.wav') || audio.src.endsWith('assets/audio/call.wav');
}
(window as unknown as { Audio: typeof Audio }).Audio = function(this: HTMLAudioElement, src?: string) {
const audio = new OriginalAudio(src);
const originalPlay = audio.play.bind(audio);
const originalPause = audio.pause.bind(audio);
audio.play = () => {
if (isCallAudio(audio)) {
callAudioState.playCount += 1;
if (audio.loop) {
callAudioState.activeLoops += 1;
}
}
return originalPlay();
};
audio.pause = () => {
if (isCallAudio(audio)) {
callAudioState.pauseCount += 1;
if (audio.loop && callAudioState.activeLoops > 0) {
callAudioState.activeLoops -= 1;
}
}
return originalPause();
};
return audio;
} as typeof Audio;
window.Audio.prototype = OriginalAudio.prototype;
Object.setPrototypeOf(window.Audio, OriginalAudio);
class MockNotification {
static permission: NotificationPermission = 'granted';
onclick: ((this: Notification, ev: Event) => unknown) | null = null;
constructor(title: string, options?: NotificationOptions) {
callNotificationState.count += 1;
callNotificationState.titles.push(title);
callNotificationState.bodies.push(options?.body ?? '');
}
static async requestPermission(): Promise<NotificationPermission> {
return 'granted';
}
close(): void {}
}
(window as unknown as { Notification: typeof Notification }).Notification = MockNotification as unknown as typeof Notification;
});
}
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, USER_PASSWORD);
await expect(page).toHaveURL(/\/search/, { timeout: 20_000 });
}
async function startCallFromSearch(page: Page, userId: string, displayName: string): Promise<void> {
await disableLastViewedChatResume(page);
await page.goto('/search', { waitUntil: 'domcontentloaded' });
const peopleCard = page.locator(`[data-testid="user-card-${userId}"]`, { hasText: displayName }).first();
await expect(peopleCard).toBeVisible({ timeout: 20_000 });
await peopleCard.hover();
await peopleCard.getByRole('button', { name: `Call ${displayName}` }).click();
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
}
function incomingCallDialog(page: Page) {
return page.getByRole('dialog', { name: /is calling/ });
}
async function answerIncomingCall(page: Page): Promise<void> {
const dialog = incomingCallDialog(page);
if (await dialog.isVisible({ timeout: 5_000 }).catch(() => false)) {
await dialog.getByRole('button', { name: 'Answer' }).click();
} else {
await page.getByRole('button', { name: 'Open private call' }).last().click();
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
const joinButton = page.getByRole('button', { name: 'Join call' });
if (await joinButton.isVisible({ timeout: 5_000 }).catch(() => false)) {
await joinButton.click();
}
}
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
await expect(page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
}
async function getCurrentUserId(page: Page): Promise<string> {
return await page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ?? '');
}
async function getCallAudioPlayCount(page: Page): Promise<number> {
return await page.evaluate(() => (window as Window & { __callAudioState?: { playCount: number } }).__callAudioState?.playCount ?? 0);
}
async function getCallAudioPauseCount(page: Page): Promise<number> {
return await page.evaluate(() => (window as Window & { __callAudioState?: { pauseCount: number } }).__callAudioState?.pauseCount ?? 0);
}
async function getCallNotificationCount(page: Page): Promise<number> {
return await page.evaluate(() => (
window as Window & { __callNotificationState?: { count: number } }
).__callNotificationState?.count ?? 0);
}
async function getActiveCallAudioLoops(page: Page): Promise<number> {
return await page.evaluate(() => (window as Window & { __callAudioState?: { activeLoops: number } }).__callAudioState?.activeLoops ?? 0);
}
async function expectParticipantConnected(page: Page, userId: string | undefined): Promise<void> {
if (!userId) {
throw new Error('Expected a stable participant id.');
}
await expect(page.getByTestId(`call-participant-${userId}`)).not.toHaveClass(/opacity-55/, { timeout: 20_000 });
}
async function assertSustainedMediaFlow(senderPage: Page, receiverPage: Page, label: string): Promise<void> {
for (let sample = 0; sample < 3; sample++) {
const [
senderAudio,
receiverAudio,
outboundVideo,
inboundVideo
] = await Promise.all([
waitForAudioFlow(senderPage, 30_000),
waitForAudioFlow(receiverPage, 30_000),
waitForOutboundVideoFlow(senderPage, 30_000),
waitForInboundVideoFlow(receiverPage, 30_000)
]);
expectAudioFlow(senderAudio, `${label} sender sample ${sample + 1}`);
expectAudioFlow(receiverAudio, `${label} receiver sample ${sample + 1}`);
expectOutboundVideoFlow(outboundVideo, `${label} outbound sample ${sample + 1}`);
expectInboundVideoFlow(inboundVideo, `${label} inbound sample ${sample + 1}`);
}
}
async function privateCallMainHasNoVerticalOverflow(page: Page): Promise<boolean> {
return await page.locator('app-private-call > section > main').evaluate((main) => main.scrollHeight <= main.clientHeight + 1);
}
async function privateCallGridStreamCount(page: Page): Promise<number> {
return await page
.getByTestId('private-call-stream-grid')
.locator('app-voice-workspace-stream-tile')
.count();
}
async function privateCallChatWidth(page: Page): Promise<number> {
return await page.locator('app-private-call aside').evaluate((aside) => aside.getBoundingClientRect().width);
}
async function hasFullscreenElement(page: Page): Promise<boolean> {
return await page.evaluate(() => document.fullscreenElement !== null);
}
async function exitFullscreen(page: Page): Promise<void> {
await page.evaluate(async () => {
if (document.fullscreenElement) {
await document.exitFullscreen();
}
});
}
function expectAudioFlow(delta: AudioFlowDelta, label: string): void {
expect(delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0, `${label} should send audio`).toBe(true);
expect(delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0, `${label} should receive audio`).toBe(true);
}
function expectOutboundVideoFlow(delta: VideoFlowDelta, label: string): void {
expect(isOutboundVideoFlowing(delta), `${label} should send video`).toBe(true);
}
function expectInboundVideoFlow(delta: VideoFlowDelta, label: string): void {
expect(isInboundVideoFlowing(delta), `${label} should receive video`).toBe(true);
}
function isAudioFlowing(delta: AudioFlowDelta): boolean {
return (delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0)
&& (delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0);
}
function isOutboundVideoFlowing(delta: VideoFlowDelta): boolean {
return delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0;
}
function isInboundVideoFlowing(delta: VideoFlowDelta): boolean {
return delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0;
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

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

View File

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

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