Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20d7f22fd2 | ||
|
|
41ebaf2407 | ||
|
|
d3d22846e7 | ||
|
|
59dfd2de85 | ||
|
|
edc4d935d8 | ||
|
|
3e090933fd | ||
|
|
590e487250 | ||
|
|
497033aff0 | ||
|
|
0078c320a5 | ||
|
|
b13f71d2d3 | ||
|
|
fa45052432 | ||
|
|
bb0ac930ad | ||
|
|
f0d79aa627 | ||
|
|
95259e8943 | ||
|
|
924d4bbb1d | ||
|
|
baa350e90a | ||
|
|
b2a2d9d770 | ||
|
|
c3c2f01cc6 | ||
|
|
dac5cb42a5 | ||
|
|
29032b5a36 | ||
|
|
e75b4a38ed | ||
|
|
07e91a0d09 | ||
|
|
cb59af6b6c | ||
|
|
6b9a39fe4a | ||
|
|
a01abbb1bf | ||
|
|
bdea95511d | ||
|
|
9981aee602 | ||
|
|
31962aeb1a | ||
|
|
79c6f91cd6 | ||
|
|
b630bacdc6 | ||
|
|
1671a04f03 | ||
|
|
cb386394d0 | ||
|
|
182828bb1e | ||
|
|
49b602dbda | ||
|
|
d72a027c9a | ||
|
|
b1b3d93851 | ||
|
|
494a05e606 | ||
|
|
5bf4f698df | ||
|
|
d174536272 | ||
|
|
d0aff6319d | ||
|
|
1274ad9b46 | ||
|
|
eb51f043ac | ||
|
|
80d7728e66 | ||
|
|
83456c018c |
@@ -195,7 +195,7 @@ export class LoginPage {
|
||||
| ------------------- | ------------------ | ----------------------- |
|
||||
| `/login` | `LoginPage` | `LoginComponent` |
|
||||
| `/register` | `RegisterPage` | `RegisterComponent` |
|
||||
| `/search` | `ServerSearchPage` | `ServerSearchComponent` |
|
||||
| `/servers` | `FindServersPage` | `FindServersComponent` |
|
||||
| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` |
|
||||
| `/settings` | `SettingsPage` | `SettingsComponent` |
|
||||
| `/invite/:inviteId` | `InvitePage` | `InviteComponent` |
|
||||
|
||||
@@ -13,6 +13,7 @@ Reference on-demand (when the workflow triggers them — see `agents-docs/AGENT_
|
||||
|
||||
- `agents-docs/AGENTS_CONTEXT.md` — contract for updating `CONTEXT.md` / `CONTEXT-MAP.md`
|
||||
- `agents-docs/AGENTS_ADRS.md` — contract for writing architecture decision records
|
||||
- `agents-docs/BUG_TRACKER.md` — Obsidian bug inbox location, allowed vault edits, and triage workflow
|
||||
|
||||
When working in a subdomain, also read its `CONTEXT.md` first:
|
||||
|
||||
@@ -74,6 +75,7 @@ The product client already maintains per-domain READMEs under `toju-app/src/app/
|
||||
- **Feature docs:** `agents-docs/features/`
|
||||
- **Architecture decisions:** `agents-docs/adr/`
|
||||
- **Context map:** `agents-docs/CONTEXT-MAP.md`
|
||||
- **Obsidian bug tracker:** `agents-docs/BUG_TRACKER.md`
|
||||
- **Product-client domain:** `toju-app/CONTEXT.md`
|
||||
- **Desktop-shell domain:** `electron/CONTEXT.md`
|
||||
- **Server domain:** `server/CONTEXT.md`
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Obsidian Bug Tracker — Agent Contract
|
||||
|
||||
User-maintained bug reports live outside the repo. Read this file when asked to triage, investigate, or work from the bug backlog.
|
||||
|
||||
**Overrides** `agents-docs/AGENT_WORKFLOW.md` §8 (Autonomous Bug Fixing) unless the user explicitly asks you to fix a bug in code.
|
||||
|
||||
---
|
||||
|
||||
## Location
|
||||
|
||||
| Item | Path |
|
||||
|------|------|
|
||||
| Bug inbox | `/home/ludde/Nextcloud/Obsidian Vault/Log/Bugs/` |
|
||||
| Attachments | `…/Bugs/attachments/<Bug title>/` |
|
||||
| Dashboard | `/home/ludde/Nextcloud/Obsidian Vault/Log/Create bug.md` |
|
||||
| Template | `/home/ludde/Nextcloud/Obsidian Vault/Log/Templates/Bug Report.md` |
|
||||
|
||||
---
|
||||
|
||||
## Allowed actions on vault files
|
||||
|
||||
Unless the user explicitly asks for more:
|
||||
|
||||
1. **Change `status`** in a bug note's YAML frontmatter (`Open` → `Resolved` or `Closed`).
|
||||
2. **Move files** (e.g. reorganize notes or attachments when instructed).
|
||||
|
||||
Do **not** edit other vault fields or sections (`Investigation`, `Resolution`, description, etc.) unless the user asks.
|
||||
|
||||
---
|
||||
|
||||
## Allowed reads (unrestricted)
|
||||
|
||||
To understand and solve bugs you may read freely:
|
||||
|
||||
- All bug notes and attachments under `Log/Bugs/`
|
||||
- The full MetoYou repo (code, tests, logs, docs)
|
||||
- Runtime output, test results, and debug artifacts
|
||||
|
||||
Investigation findings belong in chat or in repo changes — not in the vault — unless the user asks you to update the note.
|
||||
|
||||
---
|
||||
|
||||
## Bug note format
|
||||
|
||||
Each note is Markdown with YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Bug - …
|
||||
type: bug
|
||||
status: Open # Open | Resolved | Closed
|
||||
priority: Low | Medium | High | Critical
|
||||
severity: Low | Medium | High | Critical
|
||||
environment: …
|
||||
created: YYYY-MM-DD HH:mm
|
||||
tags: [bug]
|
||||
---
|
||||
```
|
||||
|
||||
Body sections: **Description**, **Steps to Reproduce**, **Expected Result**, **Actual Result**, **Logs / Screenshots**, **Investigation**, **Resolution**.
|
||||
|
||||
The dashboard (`Create bug.md`) uses Dataview; keep `type: bug` and `status` accurate so counts stay correct.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
1. List open bugs: `Glob` or `ls` on `…/Log/Bugs/*.md`, filter `status: Open`.
|
||||
2. Read the note and any linked attachments.
|
||||
3. Investigate in the repo (read-only toward the vault).
|
||||
4. Report findings to the user.
|
||||
5. Only when told to fix: implement in repo (TDD, lint, build per `AGENTS.md`).
|
||||
6. When a bug is done: update vault `status` to `Resolved` or `Closed` (and move files if the user specifies a convention).
|
||||
|
||||
---
|
||||
|
||||
## Open bugs (snapshot 2026-06-10)
|
||||
|
||||
| Title | Priority | Environment |
|
||||
|-------|----------|-------------|
|
||||
| Attachments gets syncronized corrupt | Critical | All major clients |
|
||||
| Chats doesn't sync for multi client users | High | All |
|
||||
| No android app icon | High | Android |
|
||||
| No login screen mobile phone on startup | High | Android, Android Browser |
|
||||
| Fresh users have the server list in dashboard completely empty until anything searched | High | — |
|
||||
| Video attachment on android gets sent in the message bubble above with no preview image | High | Android |
|
||||
| Local files should be remembered by client | High | — |
|
||||
| Emojis should be user bound not client bound | Medium | All |
|
||||
|
||||
Re-scan the folder at session start; this table is not auto-updated.
|
||||
+15
-2
@@ -9,14 +9,27 @@ It must stay accurate as new features are introduced, renamed, merged, or remove
|
||||
## Feature list (alphabetical)
|
||||
|
||||
- [App i18n](features/app-i18n.md) — `@ngx-translate/core` localization for the product client; English-only catalog today, same stack as the marketing website.
|
||||
- [Attachments](features/attachments.md) — P2P chunked file transfer over WebRTC data channels with Electron/Capacitor disk persistence.
|
||||
- [Authentication](features/authentication.md) — signaling-server session tokens, protected REST/WebSocket identity, and client bearer storage.
|
||||
- [Custom Emoji](features/custom-emoji.md) — peer-synced user-created emoji assets, chat reaction shortcuts, and composer emoji insertion.
|
||||
- [Desktop Local API](features/desktop-local-api.md) — Electron localhost HTTP read API, auth proxy, and offline Docusaurus docs.
|
||||
- [Direct Messaging](features/direct-messaging.md) — index entry; full contract in [Messaging](features/messaging.md).
|
||||
- [Game Activity](features/game-activity.md) — RAWG game matching, Electron process detection, and P2P now-playing sync.
|
||||
- [Invites & Join Requests](features/invites-join-requests.md) — invite links, HTML landing pages, and moderated join approval.
|
||||
- [Klipy GIFs](features/klipy-gifs.md) — server-proxied GIF search for chat and DM composers.
|
||||
- [Link Preview & Media Proxy](features/link-preview-media-proxy.md) — SSRF-guarded link unfurling and image proxy on the signaling server.
|
||||
- [Message Integrity](features/message-integrity.md) — signed P2P message revision chains, inventory `headHash` convergence, and Ed25519 signing-key registration on the signaling server.
|
||||
- [Messaging](features/messaging.md) — server-channel chat, direct messages, inventory sync, and DM delivery state machine.
|
||||
- [Mobile Capacitor](features/mobile-capacitor.md) — Capacitor native shell, mobile infrastructure facades, and phone-specific call/chat/media integrations.
|
||||
- [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints (server) consumed by the `/dashboard` and `/servers` client pages.
|
||||
- [Plugins](features/plugins.md) — client plugin runtime, server metadata API, Electron plugin data, and P2P message bus.
|
||||
- [Push Notifications](features/push-notifications.md) — FCM/APNs device tokens on the server and Capacitor registration.
|
||||
- [Server Directory](features/server-directory.md) — multi-endpoint catalog, REST CRUD/join/moderation, and room signal affinity.
|
||||
- [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints consumed by `/dashboard` and `/servers`.
|
||||
- [Signaling](features/signaling.md) — canonical WebSocket envelope catalog, ordering invariants, and relay rules.
|
||||
- [Signal Server Tag](features/signal-server-tag.md) — configurable signal-server display tag shown on profile cards for a user's registration server.
|
||||
- [Voice & WebRTC](features/voice-webrtc.md) — voice/camera/screen-share WebRTC with signaling relay and multi-device ownership.
|
||||
|
||||
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.
|
||||
The product client also documents its bounded contexts at `toju-app/src/app/domains/<name>/README.md` (Access Control, Attachment, Authentication, Chat, Custom Emoji, 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.
|
||||
|
||||
|
||||
@@ -25,6 +25,160 @@ Durable rules for AI agents working on this project. Read this file at session s
|
||||
|
||||
## Lessons
|
||||
|
||||
### Keep `NgOptimizedImage` off runtime blob and data URLs [angular] [images]
|
||||
|
||||
- **Trigger:** Angular template lint suggests replacing `[src]` with `ngSrc` for a user-uploaded image rendered from `blob:` or `data:`.
|
||||
- **Rule:** Keep a plain `src` binding, document/disable `prefer-ngsrc`, and use native loading/decoding plus the app's own lifecycle controls; Angular throws `NG02952` for blob/data `ngSrc`.
|
||||
- **Why:** `NgOptimizedImage` targets network/CDN images and cannot resize, preload, or safely manage renderer-created attachment blobs.
|
||||
- **Example:** chat attachment thumbnails use `[src]="attachment.objectUrl" loading="lazy" decoding="async"`, never `[ngSrc]`.
|
||||
|
||||
### Read the exact Obsidian bug note before diagnosing a named ticket [workflow] [bugs]
|
||||
|
||||
- **Trigger:** The user names a `Bug - …` ticket, but the worktree already contains plausible changes or a similarly named resolved ticket.
|
||||
- **Rule:** Resolve the exact note under `Log/Bugs/`, read every reported variant and reproduction step, and only then decide which code changes and status update belong to that ticket.
|
||||
- **Why:** attachment reload-host changes looked related to “Images and files in chat doesn't load” but came from a separate resolved ticket and did not cover the reported channel-switch state regression.
|
||||
- **Example:** read `/home/ludde/Nextcloud/Obsidian Vault/Log/Bugs/Bug - Images and files in chat doesn't load.md` before implementing or committing its fix.
|
||||
|
||||
### Run `npm run i18n:sync` after editing any `public/i18n/catalog/*.json` file [i18n] [testing]
|
||||
|
||||
- **Trigger:** Added new `call.errors.*` keys to `toju-app/public/i18n/catalog/call.json` and used them in code; the full test run failed in `app-i18n-catalog.rules.spec.ts` with "Missing i18n keys" even though the keys existed in the catalog file.
|
||||
- **Rule:** The runtime and the catalog spec read the merged `toju-app/public/i18n/en.json`, not the per-area `catalog/*.json` files — after any catalog edit, run `npm run i18n:sync` (root script, `tools/sync-app-i18n-catalog.mjs`) and commit the regenerated `en.json` alongside the catalog change.
|
||||
- **Why:** without the sync the new strings silently fall back to raw keys at runtime and the catalog spec fails, but only in the full suite — targeted spec runs of the feature under change pass, so the failure surfaces late.
|
||||
- **Example:** `npm run i18n:sync && npm run test` after adding `call.errors.microphonePermissionDenied` to `catalog/call.json`.
|
||||
|
||||
### Match direct-call recipients against every local identity alias, exactly like DMs already do [direct-call] [identity]
|
||||
|
||||
- **Trigger:** "User receiving direct call doesn't get notified" — a caller who met the callee through a room on the caller's signal server addressed the ring by the callee's *provisioned actor id*; `handleIncomingCallEvent` admitted only `payload.participantIds.includes(oderId || id)`, so the ring was silently dropped, the caller sat "In Voice", and the callee saw nothing. DMs had the identical bug fixed earlier (`baa350e`), but the fix stopped at `DirectMessageService` and never reached `DirectCallService`.
|
||||
- **Rule:** every self check on a cross-user event (admission, sender-echo filter, remote-participant filtering, DM-header peer lookup) must span all local aliases — home id, entity id, peer id, plus each `SignalServerCredentialStoreService.listValidCredentials()` actor id — and incoming aliases must be normalized onto the canonical local id before session state is keyed (`normalizeDirectCallPayloadSelfAliases`).
|
||||
- **Why:** the failure only reproduces when caller and callee have different home signal servers, which no same-server e2e covers; and when one identity-alias bug is fixed in a domain, grep for the same `=== currentUserId` pattern in sibling domains that share the transport — the direct-call domain reused `PeerDeliveryService` but kept the naive check for another month.
|
||||
- **Example:** `direct-call-participant-identity.rules.ts#directCallPayloadIncludesAnyId` / `normalizeDirectCallPayloadSelfAliases`; regression e2e `e2e/tests/voice/dm-header-call-ring.spec.ts` registers Bob on a secondary signal server, meets in a primary-signal room, and asserts the DM-header call rings Bob's incoming-call modal (fails on old code, passes after).
|
||||
|
||||
### Decide attachment receive admission once at request time; never re-gate size in the chunk handler [attachments]
|
||||
|
||||
- **Trigger:** "Sending files between users doesn't really work" — a browser user clicked Request on a 10–50 MB generic file, the request gate (`canReceiveAttachment`) admitted it for in-memory receive, the sender streamed chunks, but `handleFileChunk` still had a leftover hard `size > MAX_AUTO_SAVE_SIZE_BYTES` rejection on the in-memory path, so every chunk was dropped, no ack was ever sent, the sender's `waitForAck` timed out, and the GUI never changed.
|
||||
- **Rule:** `canReceiveAttachment` (request time) is the single admission decision; the chunk handler may only route between disk-streaming and in-memory assembly — any stricter size check there silently drops chunks the request gate already admitted.
|
||||
- **Why:** the failure is invisible in logs-from-the-outside: the sender's per-chunk sends look like a working transfer ("packages with size 32kb") until the ack timeout, and the receiver sets `requestError` only into memory that a re-request immediately clears — the user just sees a dead Request button.
|
||||
- **Example:** removed the `MAX_AUTO_SAVE_SIZE_BYTES` guard in `attachment-transfer.service.ts#handleFileChunk`; regression e2e `e2e/tests/chat/large-generic-file-transfer.spec.ts` sends an 11 MB `.bin` between two browser clients and asserts Request → progress → Download (fails on the old code, passes after).
|
||||
|
||||
### Re-queue attachment auto-downloads on every message/room binding event; never trust one transport's ordering [attachments] [realtime]
|
||||
|
||||
- **Trigger:** cross-user attachment sync e2e (`chat-message-features.spec.ts`) flaked ~50%: `file-announce` (WebRTC data channel) beat `chat-message` (signaling websocket) to the receiver, so the announce-time auto-download resolved `roomId=null`, silently gave up, and nothing ever retried — the receiver showed "Waiting for image source..." forever. A related bug: the stalled-download reset keyed only on "receivedBytes>0 && no pending request", but the pending-request marker is deleted on the *first* chunk, so any auto-download pass during an active transfer cancelled it mid-stream and the retry deadlocked against the sender's active-transfer dedupe.
|
||||
- **Rule:** events that complete the `messageId -> roomId` binding (`chat-message` in `messages-incoming.handlers.ts`) must call `queueAutoDownloadsForMessage` again — never assume `file-announce` arrives after the message, they ride different transports; and stall detection must gate on chunk-progress staleness (`lastUpdateMs` older than `ATTACHMENT_STALLED_DOWNLOAD_THRESHOLD_MS`), never on the absence of a pending-request marker alone.
|
||||
- **Why:** both halves fail silently (no error, no requestError set), so the UI just sits at 0 bytes; the flake is timing-dependent and invisible in single-client tests — only the two-client e2e with `--repeat-each` exposed it deterministically enough to fix.
|
||||
- **Example:** `handleChatMessage` now calls `attachments.queueAutoDownloadsForMessage(message.id)` after `rememberMessageRoom`; `shouldResetStalledAttachmentDownload(attachment, hasPendingRequest, nowMs)` in `attachment-autodownload.rules.ts`. Verified with `npx playwright test -g "syncs image and file attachments|syncs multi-chunk" --repeat-each=4` (8/8 after, ~50% before).
|
||||
|
||||
### Scope per-user UI state by user id, not by the client database [persistence] [multi-user] [custom-emoji]
|
||||
|
||||
- **Trigger:** custom emoji "saved library" membership was a single `savedByUser` flag on the shared emoji row plus a long-lived singleton (`CustomEmojiService`) that merged state across logins — so a second account on the same client (and the Electron shared SQLite DB) inherited the first user's picker.
|
||||
- **Rule:** when state is "per signed-in user" but the asset/row store is shared (Electron `custom_emojis`, or a renderer singleton that survives logout), key the membership by user id in its own store (`localStorage` `metoyou_custom_emoji_saved:<userId>`, mirroring the existing per-user usage ranking) and rebuild it in `loadForUser`; never rely on a global row flag or assume the singleton was reset on logout.
|
||||
- **Why:** the browser already isolates rows per-user database, so the leak only reproduces in-session (no reload) and on Electron's shared DB — both invisible if you only test reloads; a row-level flag also can't represent two local users saving the same asset.
|
||||
- **Example:** `CustomEmojiService.resolveSavedIds(userId, emojis)` reads/seeds a per-user id set; e2e `e2e/tests/chat/custom-emoji-user-binding.spec.ts` runs the whole user switch in ONE page load (client-side router nav only) so the singleton-retention leak is actually exercised, and the second user *joins* the first user's server instead of creating one (in-session "create a second server" leaves `sourceId` empty and the submit disabled).
|
||||
|
||||
### Don't strand signed-out mobile users on a logged-out dashboard [auth] [mobile] [routing]
|
||||
|
||||
- **Trigger:** `App.ngOnInit` special-cased mobile — signed-out visitors landing on `/` or `/dashboard` were kept on `/dashboard` (the "login form has no mobile chrome" rationale), so mobile users got a logged-out dashboard and never saw a login screen on startup.
|
||||
- **Rule:** decide startup routing for signed-out users with the platform-agnostic pure rule `resolveUnauthenticatedStartupRedirect(currentUrl)` (`auth-navigation.rules.ts`) — non-public routes → `/login` (with safe `returnUrl`), public routes (`/login`, `/register`, `/invite/...`) → stay; do not branch on `isMobile()` here.
|
||||
- **Why:** the mobile exception directly contradicted the product expectation ("greet signed-out users with the login screen"); the login form already links to register, so there is no dead-end to avoid.
|
||||
- **Example:** unit `auth-navigation.rules.spec.ts` (`resolveUnauthenticatedStartupRedirect('/dashboard') === { path:'/login', queryParams:{} }`); e2e `e2e/tests/mobile/mobile-login-on-startup.spec.ts` sets a 390×844 viewport **before** navigating (so `ViewportService.isMobile` is true at bootstrap) and asserts `/dashboard` and `/` both land on `/login`.
|
||||
|
||||
### "Shared from your device" must gate on local bytes, not uploader user id [attachments] [multi-device]
|
||||
|
||||
- **Trigger:** a second device of the same user showed "Shared from your device" and hid the download affordance for a file uploaded from another device — `isUploader(attachment)` returned `uploaderPeerId === currentUserId`, but `uploaderPeerId` is the **user** id (set to `currentUser.id` in `publishAttachments`), so it is true on every device of the uploader, including ones that only synced metadata.
|
||||
- **Rule:** key the sharing/ownership UI off whether *this device* holds the bytes, not who uploaded it — use `isSharingFromThisDevice(attachment, currentUserId)` (= `isUploaderUser && deviceHasLocalCopy`) from `attachment-sharing.rules.ts`; `deviceHasLocalCopy` = `available` + blob `objectUrl`, or a non-empty `savedPath`/`filePath` (synced metadata strips local paths, so it correctly reads as "no copy").
|
||||
- **Why:** same-user devices do **not** P2P with each other and sync only via `account_sync` (which strips `filePath`/`savedPath`), so the second device legitimately has no bytes; claiming ownership blocked the only path to view/download. For the regression to even be reachable in e2e, `account_sync`'s `chat-sync-batch` had to start carrying the `attachments` map (it previously dropped attachment metadata entirely) via `pushSavedRoomMessagesViaAccountSync(..., loadAttachmentMetas)`.
|
||||
- **Example:** unit `attachment-sharing.rules.spec.ts` (`isSharingFromThisDevice({uploaderPeerId:'u1', available:false}, 'u1') === false`); e2e `e2e/tests/chat/multi-device-attachment-sharing.spec.ts` uploads on device A then logs device B in afterward so the `account_sync_peer_online` full-state push delivers the attachment, then asserts device B shows a Request button and **no** "Shared from your device".
|
||||
|
||||
### Generate Android brand icons from the source mark; guard against stock Capacitor placeholders [mobile] [android] [assets]
|
||||
|
||||
- **Trigger:** the Android app shipped the default Ionic/Capacitor launcher icon (and a white adaptive background) because no brand icon was ever generated into `toju-app/android/app/src/main/res/`.
|
||||
- **Rule:** regenerate launcher + splash from `images/icon-new-rounded.png` with `npm run cap:assets:android` (`tools/generate-android-app-icons.mjs`, uses `sharp`), set the adaptive background to brand purple `#4A217A` (never `#FFFFFF`), and have the adaptive icon reference `@mipmap/ic_launcher_foreground` PNGs (delete the stock `drawable-v24/ic_launcher_foreground.xml` vector). `cap:sync` is not needed — these live in the native project, not `webDir`.
|
||||
- **Why:** a native launcher icon can't be asserted through a browser, so the regression proof is a hash guard: `mobile-android-launcher-icon.rules.ts` records the SHA-256 of every stock placeholder and the tests fail if any density still matches one. Pixel checks (purple ring + white-cat centre) confirm the brand mark actually rendered.
|
||||
- **Example:** `findStockCapacitorResources(hashByFile)` must return `[]`; unit `mobile-android-launcher-icon.rules.spec.ts` + e2e `e2e/tests/mobile/android-app-icon.spec.ts` (deterministic fs/pixel checks, no emulator).
|
||||
|
||||
### Bind chat attachments to a pre-allocated message id, never by matching content [attachments] [chat] [mobile]
|
||||
|
||||
- **Trigger:** caption-less media (videos/images sent with no text) grouped onto the message bubble above and left an empty message below on Android — `ChatMessagesComponent` dispatched `sendMessage` without an id, then a `setTimeout` re-discovered the message by `entry.content === content` (always `''` for attachment-only sends) and called `publishAttachments` on it.
|
||||
- **Rule:** pre-allocate the message id in the component (`planChatMessageSend` in `chat-message-send.rules.ts`), dispatch it via `MessagesActions.sendMessage({ id, ... })` (effect uses `id ?? uuidv4()`), and bind attachments to that exact id with `publishAttachments(id, files)` — never re-find the message by content/timing.
|
||||
- **Why:** empty content is shared by every attachment-only message, so content matching picks the newest match and races the async create-effect; on Android the create latency exceeds the old 100 ms timer, so the file binds to a stale sibling. The race is invisible on fast desktop browsers, so the deterministic regression proof is the unit test that asserts the dispatched action id equals the attachment-binding id, not an e2e timing game (see the "don't bump E2E timeouts for sync flakes" lesson).
|
||||
- **Example:** `planChatMessageSend(...).attachmentBinding.messageId === plan.action.id` enforced in `chat-message-send.rules.spec.ts`; behavioral guard in `e2e/tests/chat/attachment-only-message-grouping.spec.ts` (proves the id flows component→effect→attachment by requiring each caption-less attachment to render in its own bubble).
|
||||
|
||||
### Attachment file persistence must be platform-agnostic, not Electron-only [attachments] [persistence] [mobile]
|
||||
|
||||
- **Trigger:** `AttachmentStorageService` talked only to `window.electronAPI`, so `canWriteFiles()` returned `false` on Android (Capacitor) and in the browser — no bytes were ever persisted there, and after restart/logout-login the uploader hit "Your original upload could not be found on this device" / "no peer with this file".
|
||||
- **Rule:** keep the path/bucket layout in `AttachmentStorageService` but delegate raw IO to a pluggable `AttachmentFileStore` selected by `PlatformService` — Electron disk, Capacitor `Directory.Data` (lazy-loaded, inline media via `convertFileSrc`), and a per-user IndexedDB vfs for the browser with a finite `maxPersistableBytes` cap; gate transfer persistence on `canStreamToDisk()` / `canPersistSize()` so the cap degrades gracefully.
|
||||
- **Why:** the browser e2e harness can't test native disk, but the browser IndexedDB store is real persistence, so a single-client send → `page.reload()` → reopen-room test proves the whole persist/restore orchestration with no peer connected.
|
||||
- **Example:** `attachment-file-store.ts` + `{electron,browser,capacitor}-attachment-file-store.ts`; `e2e/tests/chat/local-attachment-persistence.spec.ts` waits for both byte records (vfs) **and** `attachments` records with `savedPath` (summed across all `metoyou`/`metoyou::<user>` DBs, since an empty anonymous-scope DB exists) before reloading.
|
||||
|
||||
### Never count duplicate chunks toward transfer progress, and never finalize on byte counters [attachments] [webrtc]
|
||||
|
||||
- **Trigger:** P2P attachments arrived corrupt everywhere ("only the first bytes") because concurrent auto-download triggers double-requested a file, the sender streamed it twice, and the receiver counted duplicate chunk deliveries toward `receivedBytes` — inflating it past `size`, which both dropped the remaining chunks (post-Security guard) and passed the `receivedBytes >= size` finalize shortcut over a sparse buffer.
|
||||
- **Rule:** in chunked transfer receivers, ignore an already-buffered chunk index entirely (no progress update), use dense buffers, and finalize only when every chunk index is present — never use byte totals as an alternative completion signal; dedupe streams on the sender per `(messageId, fileId, peerId)`.
|
||||
- **Why:** byte counters lie as soon as any duplicate, retry, or concurrent stream exists, and sparse-array `every`/`some` skip holes, so "looks complete" checks silently pass on partial data (same trap as the custom-emoji sparse-array lesson).
|
||||
- **Example:** `handleFileChunk` / `finalizeTransferIfComplete` in `attachment-transfer.service.ts`; multi-chunk e2e coverage via `expectMessageImageContentSha256` in `e2e/tests/chat/chat-message-features.spec.ts` (single-chunk files cannot catch assembly bugs — test with >64 KiB payloads).
|
||||
|
||||
### Don't bump E2E timeouts for sync flakes - gate on presence and read server logs [testing] [realtime]
|
||||
|
||||
- **Trigger:** a multi-client chat-sync E2E flaked on "message not visible" and the first instinct was to raise `toBeVisible` timeouts or add waits; the user correctly rejected this ("it's not a timeout issue").
|
||||
- **Rule:** when a cross-user E2E assertion flakes, first gate the assertion on an observable precondition (peer visible in the members panel), then diff the signaling-server logs of a passing vs failing run (`joined server`, `user_joined`, `user_left`, `Removing dead connection`) before touching any timeout.
|
||||
- **Why:** the flake was a server race — `identify` + `join_server` arriving in one TCP segment were processed concurrently, the join was dropped as unauthenticated, and room membership silently vanished; no timeout can fix a message that is never broadcast. Fixed by serializing per-connection message handling in `server/src/websocket/handler.ts`.
|
||||
- **Example:** failing run showed one `joined server` for Ludde then `user_left` on sibling-client close; passing run showed two. `expectServerPeerVisible(page, displayName)` in `e2e/helpers/multi-device-session.ts` is the presence gate.
|
||||
|
||||
### When renaming an Angular route, sweep every navigate/url-match/doc reference [routing]
|
||||
|
||||
- **Trigger:** the find-servers route was renamed `/search` → `/servers` in `app.routes.ts`, but `servers-rail.component.ts` still called `router.navigate(['/search'])` (leave-server) and matched `startsWith('/search')` for the user-bar visibility signal, throwing `NG04002: 'search'` on leave and never showing the user-bar on the discovery page.
|
||||
- **Rule:** after changing a `path:` in `app.routes.ts`, grep the whole repo for the old literal (`/search`) across `*.ts`/`*.html` (router calls, `startsWith`/url-match signals) and docs (`docs-site`, `.agents/skills/playwright-e2e/SKILL.md` route tables, domain READMEs) and update them all in the same change.
|
||||
- **Why:** `router.navigate` to a non-existent path raises `NG04002` and aborts navigation, and stale `startsWith` matches silently break route-derived UI state — neither is caught by the build (string literals) and there was no `servers-rail` spec to catch it.
|
||||
- **Example:** fixed `isOnServers`/`router.navigate(['/servers'])` in `servers-rail.component.{ts,html}`; canonical post-leave/discovery route is `/servers` (`FindServersComponent`), matching `DashboardComponent`'s `router.navigate(['/servers'])`.
|
||||
|
||||
### Server discovery must fan out across all endpoints and self-heal on 404 — never hardcode a host capability blocklist [server-directory]
|
||||
|
||||
- **Trigger:** the dashboard "Popular Servers" and `/servers` discovery view were empty for fresh users until they typed a search. The first fix added a static `DISCOVERY_UNSUPPORTED_HOSTS` blocklist (`signal.toju.app` / `signal-sweden.toju.app`) that short-circuited discovery to `[]`; the production hosts later shipped the `/featured` + `/trending` routes (verified `curl` → 200 with servers), so the stale blocklist kept blocking exactly the default endpoints a fresh account has while ungated search still surfaced them.
|
||||
- **Rule:** discovery (`getFeaturedServers`/`getTrendingServers`) must fan out across `getSearchableEndpoints()` with `forkJoin` + `deduplicateById` (mirroring all-endpoint search), and detect capability *at runtime* — on a `404` from `/api/servers/{featured,trending}`, fall back per-endpoint to the public `GET /api/servers` listing (`fetchPublicServerListForDiscovery`) instead of returning `[]`. Do not maintain a hardcoded list of hosts that "don't support" a route; it goes stale silently and the build can't catch it.
|
||||
- **Why:** legacy servers resolve `/featured` as `/servers/:id` and answer 404, so a 404→fallback keeps the default view populated everywhere without a blocklist; the empty-query view renders discovery sections (not search results), so any divergence between discovery and search makes it look broken while search works.
|
||||
- **Example:** `fetchDiscoveryFromEndpoint` + `fetchPublicServerListForDiscovery` in `server-directory-api.service.ts`; `e2e/tests/servers/server-discovery-default.spec.ts` proves a fresh account sees Popular Servers without searching AND that route-intercepting `/featured`+`/trending` to 404 still populates it via the fallback.
|
||||
|
||||
### Server registration needs `ownerPublicKey: oderId || id`, and must not be fire-and-forget [server-directory] [rooms]
|
||||
|
||||
- **Trigger:** creating a server appeared to work (the creator landed in the room view) but the server didn't exist on the backend — invite-link creation and search both 404'd. `createRoom$` sent `ownerPublicKey: currentUser.oderId` with no fallback; on restored sessions `oderId` can be falsy (identify still works because it falls back to `id`), so `POST /api/servers` returned `400 Missing required fields`, and the `.subscribe()` swallowed the error while `createRoomSuccess` fired regardless.
|
||||
- **Rule:** resolve owner identity as `oderId || id` everywhere it's required (the server rejects an empty `ownerPublicKey`), and give `registerServer().subscribe()` an `error` handler so a failed registration is never silent.
|
||||
- **Why:** verified against the live server — authed POST with a truthy `ownerPublicKey` → 201; authed POST with an empty one → 400; the swallowed 400 is exactly what produces a "ghost" room the creator can enter but no one can find.
|
||||
- **Example:** `buildServerRegistrationPayload(room, currentUser, normalizedPassword)` in `toju-app/src/app/store/rooms/server-registration.rules.ts`, used by `RoomsEffects.createRoom$`.
|
||||
|
||||
### Identify must fall back to the legacy session token, not only the new credential store [realtime] [authentication]
|
||||
|
||||
- **Trigger:** the multi-signal-server auth refactor changed `resolveCredentialForSignalUrl` to read *only* `SignalServerCredentialStoreService`; sessions restored from disk (and logins where `user.homeSignalServerUrl` is unset) have an empty credential store, so `identify` was skipped on every signal server ("Skipping identify because no session token is available") and users appeared alone — no presence, no peers, sent messages visible only to themselves. E2E never caught it because every e2e flow does a *fresh* register/login that writes the credential store directly.
|
||||
- **Rule:** when resolving the identify credential for a signal URL, prefer the per-signal credential but fall back to the legacy `AuthTokenStoreService` token reconstructed with the current home user's `id`/`displayName`; never gate `identify` solely on the new credential store.
|
||||
- **Why:** `persistSessionToken` always writes the legacy `metoyou.authTokens` store on login, but the per-signal credential store is only populated on fresh login (with a `loginResponse`) or successful migration/provisioning — so on reload it can be empty while a valid session still exists.
|
||||
- **Example:** `resolveSignalIdentity(credential, legacyTokenEntry, homeUser)` in `signal-server-credential-resolution.rules.ts`, wired through `SignalServerAuthService.resolveCredentialForSignalUrl` (which now passes `this.authTokenStore.getTokenEntry(httpUrl)` and a `homeUser` carrying `id`). Test cross-user behavior via a *session-restore* path, not just fresh login.
|
||||
|
||||
### Keep the per-signal-URL identify credential resolvable from the store [realtime] [authentication]
|
||||
|
||||
- **Trigger:** after the multi-signal-server auth refactor, `SignalingManager.getLastIdentify` was switched to `getIdentifyCredentialsForSignalUrl`, which only read an in-memory cache populated *after* `identify()` ran; a freshly (re)connected socket then emitted `join_server` before any identify and users silently never appeared in the presence roster (almost all multi-user e2e tests timed out waiting for the peer's `room-user-card`).
|
||||
- **Rule:** `getIdentifyCredentialsForSignalUrl` must fall back to resolving the credential from the credential store so a new socket's `onopen` re-identifies before it re-joins; never restrict it to only the in-memory identify cache.
|
||||
- **Why:** the server drops `join_server`/`view_server` on any unauthenticated connection, so an identify-less join is lost with no error and recovery only happens on a later reconnect (often beyond the 20s test timeout).
|
||||
- **Example:** server log showed `join_server authed=false ... display=User` dropped, then `User identified: Alice` on a different connection but no `Alice joined server`; fixed in `signaling-transport-handler.ts` by resolving via `dependencies.resolveCredential(signalUrl)` when the cache is empty.
|
||||
|
||||
### Store clientInstanceId in sessionStorage not localStorage [realtime] [multi-device]
|
||||
|
||||
- **Trigger:** same user logged in on two tabs, browsers, or synced profiles sees alternating "Disconnected from signaling server" and no cross-device chat/voice sync.
|
||||
- **Rule:** persist `metoyou.clientInstanceId` in `sessionStorage` (one id per tab/window) and clear any legacy `localStorage` copy on first read.
|
||||
- **Why:** server identify evicts stale sockets with the same `(oderId, connectionScope, clientInstanceId)` tuple; a shared localStorage id makes each client kick the other in a reconnect loop.
|
||||
- **Example:** `ClientInstanceService.getClientInstanceId()` writes to `sessionStorage`; two tabs get different ids and stay connected simultaneously.
|
||||
|
||||
### Revalidate IndexedDB scope without reinitializing on every read [persistence] [performance]
|
||||
|
||||
- **Trigger:** `DatabaseService.ensureReady()` called `initialize()` before every delegated read/write to fix user-scope races.
|
||||
- **Rule:** cache the last validated `metoyou_currentUserId` and only re-run backend initialization when that scope changes or an in-flight initialize completes with a different scope.
|
||||
- **Why:** per-operation revalidation fans out across ban lookups, room loads, and message reads, causing channel/chat UI to stay blank until repeated server clicks eventually win the race.
|
||||
- **Example:** `ensureReady()` returns immediately when `isReady()` and `validatedUserScope` still match `getStoredCurrentUserId()`.
|
||||
|
||||
### Restore local user scope before protected writes [authentication] [persistence]
|
||||
|
||||
- **Trigger:** a logged-in in-memory user can create rooms or messages after `metoyou_currentUserId` was cleared by a late session-expired path.
|
||||
- **Rule:** before protected local persistence or server-directory actions, restore `metoyou_currentUserId` from the current user and avoid treating a live current user as unauthenticated.
|
||||
- **Why:** otherwise rooms/messages fall into the anonymous IndexedDB scope, and route checks redirect to login even though NgRx still has the authenticated user.
|
||||
- **Example:** `MessagesEffects.sendMessage$`, `RoomsEffects.createRoom$`, and server-directory create/join components call `setStoredCurrentUserId(currentUser.id)` before writing or joining.
|
||||
|
||||
### Persisted local user state still requires a session token [authentication] [signaling]
|
||||
|
||||
- **Trigger:** Users appear logged in from local storage but cannot see peers online or send chat after session-token auth shipped.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# ADR-0003: Multi-Client Sessions with Connection-Scoped Routing
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
Users expect to stay logged in on multiple devices simultaneously (Discord-style). The signaling server already issued multiple session tokens per user, but WebSocket broadcasts deduplicated by `oderId`, which prevented a user's second device from receiving chat, typing, or voice-state updates from their first device. Voice had no per-device identity, so two clients could both attempt to transmit audio.
|
||||
|
||||
## Decision
|
||||
Introduce a stable per-install `clientInstanceId` on the product client. Route server broadcasts by **connection id** (exclude only the sender socket) while keeping presence `user_joined` / `user_left` identity-scoped. Track `voiceActive` per connection; relay RTC to the voice-active socket. Enforce single voice owner per user via `VoiceState.clientInstanceId` and `voice_client_takeover` handoff between connections.
|
||||
|
||||
## Consequences
|
||||
- **Positive:** Chat and presence sync across a user's devices; voice behaves like Discord (one transmitting client, passive viewers, explicit takeover).
|
||||
- **Positive:** Stale-tab hygiene uses `(oderId, connectionScope, clientInstanceId)` eviction without kicking other devices.
|
||||
- **Negative:** `findUserByOderId` semantics change — RTC now prefers voice-active connections; callers must not assume one socket per user.
|
||||
- **Negative:** Clients must include `clientInstanceId` on identify and voice payloads; older builds without it still work but cannot participate in multi-device voice exclusivity reliably.
|
||||
@@ -1,7 +1,14 @@
|
||||
# App i18n
|
||||
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
Client-side UI string localization for the product client (`toju-app`), using the same `@ngx-translate/core` stack as the marketing website.
|
||||
|
||||
## Migration status
|
||||
|
||||
Only **English** ships today (`SUPPORTED_APP_LOCALES = ['en']`). The catalog workflow and `translate` pipe are in place, but many components still use hardcoded strings — new user-visible copy should use i18n keys; migrate adjacent strings when touching a component. There is no locale preference UI yet.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Bundle locale JSON under `toju-app/public/i18n/`.
|
||||
@@ -60,3 +67,14 @@ The sync script also extracts `theme.registry.*` labels/descriptions from `theme
|
||||
- `toju-app/src/app/core/i18n/app-i18n.rules.spec.ts`
|
||||
- `toju-app/src/app/core/i18n/app-i18n.service.spec.ts`
|
||||
- `toju-app/src/app/core/i18n/app-i18n.testing.ts` — `provideAppI18nForTests()` / `initializeAppI18nForTests()` for Vitest injectors
|
||||
|
||||
## Related
|
||||
|
||||
- `toju-app/AGENTS.md` — i18n usage rules for agents
|
||||
- Marketing site i18n is separate: `website/public/i18n/`
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Documented partial migration status and locale UI gap |
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Attachments
|
||||
|
||||
> **Area:** attachments
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
Attachments move file bytes peer-to-peer over the WebRTC ordered data channel using a announce → request → chunk protocol. Chat and DMs attach metadata to messages; the signaling server does not store or relay file payloads. Sibling devices learn attachment **metadata** via `account_sync` `chat-sync-batch` but must still download bytes from a peer that has them.
|
||||
|
||||
Domain internals: [`toju-app/src/app/domains/attachment/README.md`](../../toju-app/src/app/domains/attachment/README.md).
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Chunked P2P transfer with flow control and cancel semantics.
|
||||
- Auto-download when policy allows; disk streaming on Electron/Capacitor.
|
||||
- Ownership vs "shared from your device" UI rules.
|
||||
- Persist attachment rows + filesystem paths on desktop/mobile.
|
||||
|
||||
This area does **not** own:
|
||||
|
||||
- Message envelopes or delivery states → [messaging.md](messaging.md).
|
||||
- WebRTC negotiation → [voice-webrtc.md](voice-webrtc.md).
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **Announce** — sender advertises `fileId`, name, size, mime without sending bytes.
|
||||
- **Mirror host** — peer that holds a complete copy and can serve chunks.
|
||||
- **Buffered send** — waits for data-channel back-pressure (4 MB high / 1 MB low water marks on chat channel).
|
||||
|
||||
---
|
||||
|
||||
## P2P protocol
|
||||
|
||||
| type | Purpose |
|
||||
|------|---------|
|
||||
| `file-announce` | Metadata only |
|
||||
| `file-request` | Receiver starts download |
|
||||
| `file-chunk` | Base64 chunk (`index`, `total`, `data`) |
|
||||
| `file-chunk-ack` | Per-chunk flow control |
|
||||
| `file-cancel` | Abort in flight |
|
||||
| `file-not-found` | Host lacks bytes |
|
||||
|
||||
**Chunk size:** `FILE_CHUNK_SIZE_BYTES` = **64 KB** (`attachment-transfer.constants.ts`).
|
||||
|
||||
**Electron send path:** reads one chunk at a time from disk via IPC (`append-file-bytes` / read chunk) to avoid loading whole files into renderer memory.
|
||||
|
||||
---
|
||||
|
||||
## Persistence
|
||||
|
||||
| Runtime | Metadata | Bytes |
|
||||
|---------|----------|-------|
|
||||
| Browser | In-memory / optional save | Below **10 MB** auto-save cap (`MAX_AUTO_SAVE_SIZE_BYTES`) |
|
||||
| Electron | SQLite `attachments` + CQRS | `user/<username>/…` via `AttachmentStorageService` / IPC |
|
||||
| Capacitor | SQLite | App-private attachment directory — [mobile-capacitor.md](mobile-capacitor.md) |
|
||||
|
||||
---
|
||||
|
||||
## Download / export to user location
|
||||
|
||||
`AttachmentDownloadService.downloadToUserLocation` picks the runtime-appropriate export path:
|
||||
|
||||
| Runtime | Behavior |
|
||||
|---------|----------|
|
||||
| Electron | `saveExistingFileAs` (disk-backed) or `saveFileAs` (blob) native save dialog |
|
||||
| Browser | Anchor `download` click on the object URL |
|
||||
| Capacitor | `CapacitorAttachmentExportService.exportToDevice`: copies the disk file from `Directory.Data` into `Directory.Documents` (or fetches the object URL and writes base64) using `buildAttachmentExportFileName` (timestamp suffix so exports never collide — Android 11+ rejects overwrites of files the app did not create). Anchor downloads do nothing in the Android WebView. |
|
||||
|
||||
## Multi-device
|
||||
|
||||
`chat-sync-batch` in `account_sync` carries an `attachments` map (local paths stripped). Sibling devices discover files exist; P2P `file-request` still required for bytes.
|
||||
|
||||
---
|
||||
|
||||
## Business rules and invariants
|
||||
|
||||
- Transfers are between connected peers only (no server CDN).
|
||||
- Receive strategy is decided once at request time by `canReceiveAttachment` (`attachment.logic.ts`): ≤ 10 MB assembles in memory everywhere; > 10 MB streams to disk on Electron/Capacitor, assembles in memory on the browser up to its 50 MB persist cap, and is rejected with a visible `fileTooLarge` error beyond that. `handleFileChunk` must accept whatever the request gate admitted — a stricter chunk-time size cap silently drops chunks and stalls the transfer.
|
||||
- Visibility-based blob lifecycle on desktop: revoke `blob:` URLs when messages scroll off-screen if disk can rehydrate.
|
||||
- Startup hydration is an availability boundary: file requests and host re-announcements wait for persisted metadata before inspecting local files. A host re-announces persisted files after reload and on peer connection even from non-chat routes, and can recover an original Electron source path by copying it into app data on demand before serving.
|
||||
- Startup database hydration merges persisted metadata into the live runtime attachment map; it must preserve attachments announced during initialization and completed runtime state (`available`, progress, display URL) while filling missing local paths. Replacing the map can regress a completed download to Retry, spinner, or 100% after navigation.
|
||||
- Starting a request updates the runtime version immediately so inline cards and galleries show pending/download state at zero bytes; exhausting all candidate peers surfaces `fileNotFound` instead of silently clearing the pending request. A repeat host announce re-queues guarded auto-download recovery for eligible media.
|
||||
- Display-blob memory invariants (added 2026-07-14, RAM investigation):
|
||||
- Inline hydration (`chat-message-item` effect) only runs for messages that are visible or within the `IntersectionObserver` root margin — gated by `attachment-hydration-visibility.rules.ts`. Off-screen rows never load blobs.
|
||||
- Visibility observation uses the rendered message row (`componentHost.firstElementChild`), not the boxless Angular component host; otherwise returning to a channel leaves revoked attachments on permanent spinners.
|
||||
- Disk-to-blob hydration is deduplicated per attachment and capped at two active tasks. Offscreen/destroy lifecycle cancellation is checked after every IPC read and before object-URL assignment, so rapid channel switches cannot accumulate stale full-file buffers or orphaned blobs; pinned fullscreen/gallery attachments are exempt.
|
||||
- Disk-hydrated blobs are **not** duplicated into `AttachmentRuntimeStore.originalFiles`; peer requests are served from the disk path (`streamRequestedFile` prefers `resolveExistingPath`). `originalFiles` only holds uploads/downloads that have no disk copy yet.
|
||||
- `revokeAttachmentDisplayBlob` also drops the `originalFiles` entry when `savedPath` exists, so revocation actually frees the bytes.
|
||||
- Message rows always revoke their display blobs on destroy (pins are respected), not only when they were visible.
|
||||
- Room switch sweeps display blobs of all other rooms (`releaseDisplayBlobsForInactiveRooms`, driven by `collectMessageIdsForInactiveRoomBlobRelease`). Messages with unknown room mapping are left alone.
|
||||
- "Shared from your device" badge only when bytes are local to the viewing user.
|
||||
- Blob-backed chat thumbnails use plain `src` with native lazy loading and async decoding. `NgOptimizedImage` is forbidden for these URLs because Angular rejects `blob:` inputs; fullscreen images remain eager.
|
||||
|
||||
---
|
||||
|
||||
## Technical implementation
|
||||
|
||||
- Facade: `AttachmentFacade` → `AttachmentManagerService`
|
||||
- Protocol: `AttachmentTransferService` + `AttachmentTransferTransportService`
|
||||
- Electron IPC: `read-file-chunk`, `append-file-bytes`, `write-file`, `delete-file`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- Domain logic specs under `attachment/`
|
||||
- E2E: `e2e/tests/chat/chat-message-features.spec.ts`, `local-attachment-persistence.spec.ts`, `multi-device-attachment-sharing.spec.ts`, `large-generic-file-transfer.spec.ts` (browser receiver, generic file above the 10 MB auto-save cap)
|
||||
|
||||
---
|
||||
|
||||
## Security considerations
|
||||
|
||||
- No server-side virus scanning; peers trust senders they are connected to.
|
||||
- Files stay in user data directories (Electron path jail).
|
||||
|
||||
---
|
||||
|
||||
## Related features
|
||||
|
||||
- [messaging.md](messaging.md) — message + attachment metadata coupling
|
||||
- [authentication.md](authentication.md) — `account_sync` batches
|
||||
- [mobile-capacitor.md](mobile-capacitor.md) — mobile storage
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-14 | Bounded display hydration to two deduplicated tasks, cancelled stale channel work before blob assignment, and added native lazy/async thumbnail hints |
|
||||
| 2026-07-14 | Fixed channel-return hydration by observing the rendered message row instead of the boxless component host |
|
||||
| 2026-07-14 | Made zero-byte requests visible, surfaced async peer-exhaustion failures, and retried eligible media when a host re-announces |
|
||||
| 2026-07-14 | Preserved live attachment/download state when startup database hydration completes after realtime events |
|
||||
| 2026-07-14 | Prevented reload-time `file-not-found` responses by waiting for metadata hydration, re-announcing hosts outside chat routes, and recovering persisted source paths on demand |
|
||||
| 2026-07-14 | Blob-memory invariants: visibility-gated hydration, no `originalFiles` duplication for disk-backed blobs, revoke-on-destroy, inactive-room blob sweep |
|
||||
| 2026-07-13 | Capacitor download/export to public `Documents` via `CapacitorAttachmentExportService` |
|
||||
| 2026-07-05 | Expanded to full contract style |
|
||||
@@ -1,6 +1,14 @@
|
||||
# Authentication
|
||||
|
||||
Session-token authentication for the signaling server and product client.
|
||||
> **Area:** authentication
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
Session-token authentication binds REST mutations and WebSocket `identify` to a user identity on each signaling server. The product client may hold **multiple** server credentials (home + foreign auto-provisioned accounts) while keeping one local user profile. Multi-device tabs share one identity via separate `clientInstanceId` values and `account_sync` relay.
|
||||
|
||||
WebSocket details: [signaling.md](signaling.md). Local API tokens: [desktop-local-api.md](desktop-local-api.md).
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
@@ -8,10 +16,16 @@ Session-token authentication for the signaling server and product client.
|
||||
|---|---|---|
|
||||
| Signaling server REST (mutations) | `Authorization: Bearer <token>` | Actor user IDs in request bodies are ignored; server derives `authUserId` from the token |
|
||||
| Signaling server REST (discovery) | None | `GET /api/servers`, featured/trending/search remain public |
|
||||
| Signaling server WebSocket | `identify.token` | Connections must identify before any other message type |
|
||||
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only |
|
||||
| Signaling server WebSocket | `identify.token` | Connections must identify before any other message type — see [signaling.md](signaling.md) |
|
||||
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only — see [desktop-local-api.md](desktop-local-api.md) |
|
||||
| Product client local DB | OS user account | SQLite and attachments are plaintext at rest |
|
||||
|
||||
## Client logout
|
||||
|
||||
- Desktop: title-bar menu **Logout** (`UserLogoutService`).
|
||||
- Mobile / all platforms: settings modal footer **Logout** (`data-testid="settings-logout-button"`) — required because the title bar is hidden on mobile breakpoints.
|
||||
- Logout disconnects realtime sessions, clears the persisted current-user id, resets NgRx room/user/message state, and navigates to `/login`.
|
||||
|
||||
## Login / register response
|
||||
|
||||
```json
|
||||
@@ -25,18 +39,86 @@ Session-token authentication for the signaling server and product client.
|
||||
```
|
||||
|
||||
- Tokens are opaque 64-character hex strings stored in server SQLite (`session_tokens`).
|
||||
- Default TTL: 24 hours (`SESSION_TOKEN_TTL_MS` env override supported).
|
||||
- Default TTL: 10 years (`SESSION_TOKEN_TTL_MS` env override supported on the signaling server).
|
||||
- Passwords are stored with bcrypt; legacy SHA-256 hashes are upgraded transparently on successful login.
|
||||
|
||||
## Protected REST routes
|
||||
|
||||
Require `Authorization: Bearer`:
|
||||
Require `Authorization: Bearer` (`requireAuth` middleware). Public routes are listed for contrast.
|
||||
|
||||
- `PUT/POST/DELETE` under `/api/servers/*` (except public `GET`)
|
||||
- `PUT /api/requests/:id`
|
||||
- Plugin-support mutations under `/api/servers/:serverId/plugins/*`
|
||||
- `/api/users/device-tokens/*`
|
||||
- `POST /api/users/logout`
|
||||
### Users (`/api/users`)
|
||||
|
||||
| Method | Path | Auth |
|
||||
|--------|------|------|
|
||||
| POST | `/register` | Public |
|
||||
| POST | `/login` | Public |
|
||||
| GET | `/:id/signing-public-key` | Public |
|
||||
| PUT | `/me/signing-key` | Bearer |
|
||||
| POST | `/logout` | Bearer |
|
||||
|
||||
### Device tokens (`/api/users/device-tokens`)
|
||||
|
||||
All routes require bearer; `userId` in body or path must equal `authUserId` (`403` otherwise).
|
||||
|
||||
| Method | Path |
|
||||
|--------|------|
|
||||
| POST | `/` |
|
||||
| GET | `/:userId` |
|
||||
| POST | `/:userId/dispatch` |
|
||||
|
||||
### Servers (`/api/servers`)
|
||||
|
||||
| Method | Path | Auth |
|
||||
|--------|------|------|
|
||||
| GET | `/`, `/featured`, `/trending`, `/:id` | Public |
|
||||
| POST | `/` | Bearer |
|
||||
| PUT | `/:id` | Bearer |
|
||||
| DELETE | `/:id` | Bearer |
|
||||
| POST | `/:id/join` | Bearer |
|
||||
| POST | `/:id/leave` | Bearer |
|
||||
| POST | `/:id/heartbeat` | Bearer |
|
||||
| POST | `/:id/invites` | Bearer |
|
||||
| GET | `/:id/requests` | Bearer |
|
||||
| POST | `/:id/moderation/kick` | Bearer |
|
||||
| POST | `/:id/moderation/ban` | Bearer |
|
||||
| POST | `/:id/moderation/unban` | Bearer |
|
||||
|
||||
### Join requests (`/api/requests`)
|
||||
|
||||
| Method | Path | Auth |
|
||||
|--------|------|------|
|
||||
| PUT | `/:id` | Bearer (approve/deny) |
|
||||
|
||||
### Plugin support (`/api/servers/:serverId/plugins`)
|
||||
|
||||
| Method | Path | Auth |
|
||||
|--------|------|------|
|
||||
| GET | `/` | Public (metadata read) |
|
||||
| PUT | `/:pluginId/requirement` | Bearer |
|
||||
| DELETE | `/:pluginId/requirement` | Bearer |
|
||||
| PUT | `/:pluginId/events/:eventName` | Bearer |
|
||||
| DELETE | `/:pluginId/events/:eventName` | Bearer |
|
||||
| GET/PUT/DELETE | `/:pluginId/data/*` | **410 Gone** (server plugin data disabled) |
|
||||
|
||||
### Public (no bearer)
|
||||
|
||||
- `GET /api/health`, `/api/time`
|
||||
- `GET /api/link-metadata`, `/api/image-proxy`
|
||||
- `GET /api/klipy/config`, `/api/klipy/gifs`
|
||||
- `POST /api/games/match`
|
||||
- `GET /api/invites/:id`
|
||||
- `GET /invite/:id` (HTML invite page)
|
||||
- OpenAPI docs routes (`/api/openapi.json`, `/api/docs`, …) — gated by server config, not session auth
|
||||
|
||||
Full server-directory semantics: [server-directory.md](server-directory.md).
|
||||
|
||||
## Message signing key registration
|
||||
|
||||
Ed25519 signing keys for [message-integrity.md](message-integrity.md) register via `PUT /api/users/me/signing-key` with `{ publicKeyJwk }`.
|
||||
|
||||
- **When registered:** `AuthenticationService` calls `MessageSigningService.registerSigningPublicKeyIfNeeded()` after successful **home** `POST /login` and `POST /register` only (`authentication.service.ts`).
|
||||
- **Scope:** registration uses the **active** signaling server's API base (`ServerDirectoryFacade.activeServer()`). Foreign-server auto-provision (`authorizeSignalServer` / `SignalServerProvisionerService`) does **not** currently call signing-key registration — message integrity on foreign servers depends on a later login path or manual registration when that server becomes active.
|
||||
- **Storage:** private key in `localStorage` (`metoyou.messageSigningKeyPair`); public key directory on server SQLite only.
|
||||
|
||||
## WebSocket identify contract
|
||||
|
||||
@@ -46,18 +128,82 @@ Require `Authorization: Bearer`:
|
||||
"token": "<session-token>",
|
||||
"oderId": "<user-id>",
|
||||
"displayName": "Alice",
|
||||
"connectionScope": "ws://host:3001"
|
||||
"connectionScope": "ws://host:3001",
|
||||
"clientInstanceId": "<per-install-uuid>"
|
||||
}
|
||||
```
|
||||
|
||||
- `oderId` must match the token's user id when provided.
|
||||
- `clientInstanceId` is a stable per-tab UUID generated by the product client (`metoyou.clientInstanceId` in `sessionStorage`). The signaling server uses it to distinguish multiple WebSocket connections for the same user and to route voice ownership.
|
||||
- Server responds with `auth_error` or `auth_required` when authentication fails.
|
||||
- **Per-connection message ordering (invariant):** the server processes WebSocket messages for one connection strictly in arrival order (`handleWebSocketMessage` chains them per connection id). `identify` awaits a DB token lookup, and clients send `identify` + `join_server` back-to-back (often one TCP segment); concurrent handling let the join run mid-identify, get rejected as unauthenticated, and silently drop room membership — that connection then missed all `user_joined` / `chat_message` broadcasts (root cause of "chats don't sync for multi-client users").
|
||||
|
||||
## Multi-device sessions
|
||||
|
||||
- Each login/register issues a **new** session token; prior tokens remain valid until they expire or the client calls `POST /api/users/logout` with that token.
|
||||
- The same user may keep multiple WebSocket connections open (different devices or browser profiles). Server broadcasts (chat, typing, voice state, status) exclude only the **sending connection**, so other connections for that identity still receive updates.
|
||||
- Voice/WebRTC is exclusive per user: only one `clientInstanceId` may own active voice at a time. Other connections show passive UI and can send `voice_client_takeover` to move voice to the local device.
|
||||
- Stale reconnect hygiene: when a client re-identifies with the same `(oderId, connectionScope, clientInstanceId)` tuple, the server closes the older socket for that tuple.
|
||||
|
||||
### Account-owned state sync (`account_sync`)
|
||||
|
||||
When the same account is logged in on multiple devices, account-owned data is kept in sync through the signaling server:
|
||||
|
||||
| Data | Mechanism |
|
||||
|---|---|
|
||||
| Server chat messages (live) | `chat_message` signaling relay (connection-scoped broadcast) **plus** `account_sync` `chat-message` / `message-revision` to sibling devices |
|
||||
| Server chat messages (catch-up) | `account_sync` `chat-sync-batch` pushed when a sibling device comes online (`account_sync_peer_online`); each batch carries its messages' **attachment metadata** (`attachments` map, local paths stripped) so sibling devices learn about synced attachments — they are then requestable/downloadable but never marked "Shared from your device" unless the bytes are local |
|
||||
| Voice / typing | Existing `voice_state` / `user_typing` relays |
|
||||
| Saved servers (join/leave) | `account_sync` payload `saved-room-sync` / `saved-room-remove` |
|
||||
| Profile avatar + card text | `account_sync` `user-avatar-full` + `user-avatar-chunk` |
|
||||
| Custom emoji library | `account_sync` `custom-emoji-full` + `custom-emoji-chunk` |
|
||||
| Friends list | `account_sync` `friend-added` / `friend-removed` |
|
||||
| Server icons, edits, reactions | `account_sync` relay of existing P2P broadcast event types |
|
||||
|
||||
Client rules:
|
||||
|
||||
- `broadcastMessage()` still fans out over peer data channels; relayable events are **also** wrapped in `account_sync` and sent on the WebSocket.
|
||||
- The server forwards `account_sync` to every other open connection for the same `oderId` via `notifyOtherConnectionsForOderId`.
|
||||
- Receivers ignore payloads whose `clientInstanceId` matches the local tab id.
|
||||
- When a new device identifies, the server notifies existing connections with `account_sync_peer_online`; those devices push a full snapshot (saved rooms, **room message history**, friends, profile, emoji library).
|
||||
|
||||
WebSocket envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "account_sync",
|
||||
"clientInstanceId": "<per-tab-uuid>",
|
||||
"payload": { "type": "saved-room-sync", "room": { "...": "..." } }
|
||||
}
|
||||
```
|
||||
|
||||
Server response to other connections includes `fromUserId` set to the sender's `oderId`.
|
||||
|
||||
## Client storage
|
||||
|
||||
The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server.
|
||||
|
||||
Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile) is **not** sufficient to use chat or presence. On startup, `loadCurrentUser$` requires a non-expired session token for the user's home/active signaling server (or any stored token as a fallback). Missing or rejected tokens dispatch `SESSION_EXPIRED` and redirect to `/login`. WebSocket `auth_required` / `auth_error` responses trigger the same path.
|
||||
Per-server credentials (`metoyou.signalServerCredentials`) map each normalized signal-server URL to the authenticated user id, username, display name, session token, expiry, and whether the account was auto-provisioned. The home user profile in SQLite/NgRx remains the device-local identity (`homeSignalServerUrl`); foreign-server credentials are a side map used for REST and WebSocket identify on that URL.
|
||||
|
||||
A per-install **provision secret** enables silent account creation on newly added or encountered signal servers. It is generated on home register/login, stored in Electron `safeStorage` when available (sessionStorage fallback on web), and never persisted as the user's visible login password.
|
||||
|
||||
### Multi-signal-server auth flows
|
||||
|
||||
| Flow | Action | Effect |
|
||||
|---|---|---|
|
||||
| Home login/register | `authenticateUser` | Resets local state, stores home credential + provision secret |
|
||||
| Foreign login/register | `authorizeSignalServer` | Upserts credential for that URL only; home session unchanged |
|
||||
| Auto-provision | `SignalServerProvisionerService` | Registers or logs in on foreign server using provision secret; on username collision tries suffixed username (`alice-<homeUserIdPrefix>`) and prefixes the display name with `#<homeUserIdPrefix> #<signalServerTag>` so same-name accounts stay distinguishable |
|
||||
| Create/join on foreign server | `RoomsEffects.createRoom$`, invite/join flows | `ensureCredentialForServerUrl` provisions (or reuses) the per-server session token first; REST/WebSocket calls use the **actor user id** for that signal URL, not the home registration id |
|
||||
| Foreign auth failure | `signalServerAuthFailed` | `auth_required` (message raced ahead of identify) re-identifies or is ignored while a valid local credential exists; `auth_error` (token rejected) clears that URL's credential and re-provisions on foreign servers or expires the home session |
|
||||
|
||||
Unreachable or offline signal servers must **not** open `/login?mode=authorize`. `ensureEndpointVersionCompatibility()` treats only `online` endpoints as connectable, and `ensureCredentialForServerUrl()` skips authorize navigation when health checks report the server offline (or provisioning fails over the network).
|
||||
|
||||
Authorize UI: `/login?mode=authorize&serverId=…&returnUrl=…` (also supported on `/register`). Settings → Network shows per-endpoint `Authorized` / `Needs sign-in` badges.
|
||||
|
||||
Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile) is **not** sufficient to use chat or presence. On startup, `loadCurrentUser$` requires a non-expired session token for the user's home signaling server (or any stored token as a fallback). Missing or rejected **home** tokens dispatch `SESSION_EXPIRED` and redirect to `/login`. Foreign-server `auth_required` / `auth_error` responses clear only that server's credential and attempt re-provision.
|
||||
|
||||
Startup routing for signed-out visitors is decided by `resolveUnauthenticatedStartupRedirect(currentUrl)` (`auth-navigation.rules.ts`), called from `App.ngOnInit`: any non-public route is redirected to `/login` (carrying a safe `returnUrl`), while public routes (`/login`, `/register`, `/invite/...`) are left alone. This is **platform-agnostic** — mobile is intentionally not special-cased, so a signed-out mobile user is greeted with the login screen on startup rather than a logged-out `/dashboard`.
|
||||
|
||||
## Security considerations
|
||||
|
||||
@@ -65,3 +211,17 @@ Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile)
|
||||
- CORS allowlist: optional `corsAllowlist` in `server/data/variables.json` or `CORS_ALLOWLIST` env (comma-separated). Empty allowlist keeps permissive CORS for local development.
|
||||
- Push-token routes require bearer auth and user-id match.
|
||||
- RTC relay: direct-message/direct-call types always relay; server-icon types require shared server membership; WebRTC offer/answer/ice remain open for cross-server DM WebRTC.
|
||||
|
||||
## Related
|
||||
|
||||
- [signaling.md](signaling.md) — WebSocket `identify`, `account_sync`, ordering invariants
|
||||
- [desktop-local-api.md](desktop-local-api.md) — Electron Local API bearer tokens
|
||||
- [message-integrity.md](message-integrity.md) — signing keys and revision chains
|
||||
- [server-directory.md](server-directory.md) — protected server REST mutations
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-14 | Distinguish `auth_required` vs `auth_error` on `signalServerAuthFailed`; stop false home-session expiry; leave `/login` when in-memory user still authenticated |
|
||||
| 2026-07-05 | Expanded protected-route inventory; clarified signing-key registration scope; cross-links |
|
||||
|
||||
@@ -2,63 +2,125 @@
|
||||
|
||||
> **Area:** custom-emoji
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-06-05
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
Custom emoji lets users upload small image emoji, use them in chat messages and reactions, and sync emoji assets needed for rendering to connected peers over the existing data-channel mesh.
|
||||
Custom emoji lets users upload small image emoji, use them in chat messages and reactions, and sync the image bytes to connected peers over the WebRTC data channel (and to sibling devices via `account_sync`). The signaling server never stores emoji assets.
|
||||
|
||||
Internal UI and NgRx wiring: [`toju-app/src/app/domains/custom-emoji/README.md`](../../toju-app/src/app/domains/custom-emoji/README.md). Chat composer integration: [`toju-app/src/app/domains/chat/README.md`](../../toju-app/src/app/domains/chat/README.md).
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Own custom emoji asset validation, local persistence, user-saved library membership, shortcut ranking, and peer-to-peer asset sync.
|
||||
- Expose a shared picker consumed by chat message reactions and the chat composer.
|
||||
- Keep usage ranking local to the current user; usage counts are not synced.
|
||||
- Does not store custom emoji on the signaling server.
|
||||
- Validate uploads (size, MIME), persist image assets locally, and track per-user **saved library** membership.
|
||||
- Rank shortcuts by local usage (not synced across devices).
|
||||
- Sync assets P2P (`custom-emoji-*` envelopes) and proactively push referenced emoji when sending messages.
|
||||
- Relay the same envelopes on `account_sync` for multi-device library convergence.
|
||||
- Expose `CustomEmojiPickerComponent` for composer and reactions.
|
||||
|
||||
## Key Concepts
|
||||
This area does **not** own:
|
||||
|
||||
- **Custom emoji asset**: A user-created image stored as a data URL with id, name, mime, size, hash, creator, timestamps, and optional saved-library membership.
|
||||
- **Known custom emoji**: A synced asset available for message rendering and forwarding, but not shown in the current user's picker unless saved.
|
||||
- **Saved custom emoji**: A known asset with `savedByUser` enabled; saved emoji appear in the picker and shortcut ranking.
|
||||
- **Emoji shortcut row**: The seven most-used emoji entries for the current user plus an eighth control that opens the full selector.
|
||||
- **Custom emoji token**: The stable message/reaction representation `:emoji[id](name)`, resolved locally to the synced image asset when rendering.
|
||||
- **Composer emoji alias**: The readable inline draft representation `:name:`. The composer rewrites known aliases to stable custom emoji tokens only when sending.
|
||||
- Message send/edit transport → [messaging.md](messaging.md).
|
||||
- Profile avatar bytes → `toju-app/src/app/domains/profile-avatar/README.md`.
|
||||
- Server-side storage (none).
|
||||
|
||||
## Peer Envelope Contract
|
||||
## Key concepts
|
||||
|
||||
Custom emoji uses `ChatEvent` data-channel envelopes:
|
||||
- **Custom emoji asset** — image with `id`, `name`, `mime`, `size`, `hash`, `creatorUserId`, `dataUrl` (or reconstructed from chunks).
|
||||
- **Known emoji** — synced for rendering; not necessarily in the picker.
|
||||
- **Saved emoji** — in the active user's library (`metoyou_custom_emoji_saved:<userId>`); shown in picker and shortcut row.
|
||||
- **Token** — stable wire form `:emoji[id](name)` in message/reaction bodies.
|
||||
- **Composer alias** — draft form `:name:` rewritten to a token on send when the name is known.
|
||||
- **Shortcut row** — seven most-used saved entries plus opener for full picker.
|
||||
|
||||
- `custom-emoji-summary`: `{ customEmojiSummaries: [{ id, hash, updatedAt }] }`
|
||||
- `custom-emoji-request`: `{ ids: string[] }`
|
||||
- `custom-emoji-full`: `{ customEmojiTransfer: Omit<CustomEmoji, 'dataUrl'>, total: number }`
|
||||
- `custom-emoji-chunk`: `{ customEmojiId, index, total, data }`
|
||||
---
|
||||
|
||||
When a peer connects, each side sends a summary of known assets. The receiver requests missing or stale emoji by id, and the owner replies with a small manifest followed by bounded base64 chunks using buffered peer sends. Creating a new emoji also streams that manifest and chunk sequence to every currently connected peer. Outgoing room chat messages, edits, reactions, and direct messages proactively push every referenced custom emoji asset to connected peers in parallel with the message event, so receivers do not wait for a request round-trip. Small assets that fit under `CUSTOM_EMOJI_INLINE_MAX_JSON_BYTES` travel inline in one `custom-emoji-full` event; larger assets use manifest plus chunks. Incoming chat messages and chat-sync batches still scan for `:emoji[id](name)` tokens and request any missing assets from the sender as a repair path. Full inline `customEmoji` payloads remain accepted for backward compatibility.
|
||||
## Peer envelope contract (P2P)
|
||||
|
||||
## Business Rules
|
||||
| type | Payload |
|
||||
|------|---------|
|
||||
| `custom-emoji-summary` | `{ customEmojiSummaries: [{ id, hash, updatedAt }] }` |
|
||||
| `custom-emoji-request` | `{ ids: string[] }` |
|
||||
| `custom-emoji-full` | manifest (`customEmojiTransfer`) ± inline bytes |
|
||||
| `custom-emoji-chunk` | `{ customEmojiId, index, total, data }` base64 |
|
||||
|
||||
- Uploads are capped at 1 MB.
|
||||
- Accepted image types match profile avatars: WebP, GIF, JPG, and JPEG.
|
||||
- Local shortcut ranking is keyed by the active user and includes Unicode emoji plus saved custom emoji only.
|
||||
- Message rendering reserves inline emoji space with a transparent placeholder image while a referenced custom emoji asset is not yet available; deferred markdown placeholders rewrite tokens to readable `:name:` aliases so raw `:emoji[id](name)` text never flashes in chat.
|
||||
- Seen custom emoji are not added to the picker automatically; right-click a rendered custom emoji in chat or on a custom emoji reaction and choose **Add to emoji library** from the app context menu (`NativeContextMenuComponent`).
|
||||
- Saved custom emoji can be removed from the picker library by right-clicking them inside the emoji picker and choosing **Remove from emoji library**; the asset stays available for rendering messages that already reference it.
|
||||
- Emoji hosts are marked with `data-custom-emoji` / `data-custom-emoji-library` plus `data-custom-emoji-id` so the global context menu can distinguish them from regular images and suppress the default **Copy Image** action.
|
||||
- The full emoji picker includes a search field that filters built-in Unicode emoji by common terms and saved custom emoji by name.
|
||||
- Custom emoji data-channel chunks are capped below typical SCTP message limits; back-pressure alone is not enough because a single oversized send can fire `RTCDataChannel.onerror`.
|
||||
- Completed transfers are persisted only when the reconstructed data URL matches the manifest size and hash; corrupt local rows are dropped before summaries are advertised.
|
||||
**Handshake:** on peer connect both sides send summaries; receiver requests stale/missing ids; owner sends manifest then chunked payloads via buffered sends.
|
||||
|
||||
## Data Access
|
||||
**Proactive push:** outgoing chat/DM messages scan for tokens and push assets to connected peers in parallel with the message event.
|
||||
|
||||
- Browser runtime stores custom emoji in IndexedDB store `customEmojis`.
|
||||
- Electron runtime stores custom emoji in SQLite table `custom_emojis`, created by migration `1000000000011-AddCustomEmojis`.
|
||||
- Renderer access goes through `DatabaseService` methods `saveCustomEmoji`, `getCustomEmojis`, and `deleteCustomEmoji`.
|
||||
**Inline threshold:** assets ≤ `CUSTOM_EMOJI_INLINE_MAX_JSON_BYTES` (48 KiB) ship in one `custom-emoji-full`; larger assets use manifest + chunks.
|
||||
|
||||
**Repair path:** incoming messages and `chat-sync-batch` scan for tokens and request missing assets from the sender.
|
||||
|
||||
### Multi-device (`account_sync`)
|
||||
|
||||
Relayable types (`account-sync.rules.ts`): `custom-emoji-summary`, `custom-emoji-request`, `custom-emoji-full`, `custom-emoji-chunk`. See [signaling.md](signaling.md) and [authentication.md](authentication.md).
|
||||
|
||||
---
|
||||
|
||||
## Business rules and invariants
|
||||
|
||||
- Max upload **1 MB**; MIME: WebP, GIF, JPEG/JPG (same set as profile avatars).
|
||||
- Library membership is **per user id**, not per device — second account on same machine does not inherit another user's saved set.
|
||||
- Seeing an emoji in chat does **not** add it to the library; user must **Add to emoji library** from context menu.
|
||||
- Remove from library hides picker entry but keeps asset for messages that already reference it.
|
||||
- Chunks stay below SCTP-safe sizes; oversized single sends can trigger `RTCDataChannel.onerror` even when back-pressure is idle.
|
||||
- Persist only when reconstructed `dataUrl` matches manifest **size and hash**; corrupt rows are dropped before advertising summaries.
|
||||
- Placeholder rendering avoids flashing raw tokens while assets are in flight.
|
||||
|
||||
---
|
||||
|
||||
## Storage
|
||||
|
||||
| Runtime | Asset bytes | Library membership |
|
||||
|---------|-------------|-------------------|
|
||||
| Browser | IndexedDB `customEmojis` (per-user DB scope) | `localStorage` `metoyou_custom_emoji_saved:<userId>` |
|
||||
| Electron | SQLite `custom_emojis` (shared desktop DB) | same localStorage key |
|
||||
| Capacitor | SQLite `custom_emojis` in `metoyou__<userId>` | same localStorage key |
|
||||
|
||||
API: `DatabaseService.saveCustomEmoji` / `getCustomEmojis` / `deleteCustomEmoji`.
|
||||
|
||||
---
|
||||
|
||||
## Technical implementation
|
||||
|
||||
- Rules: `domains/custom-emoji/domain/custom-emoji.rules.ts`
|
||||
- Service: `CustomEmojiService`; effects: `CustomEmojiSyncEffects`
|
||||
- Picker: `feature/custom-emoji-picker/`
|
||||
- Context menu: `data-custom-emoji` / `data-custom-emoji-library` attributes on rendered hosts
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests cover upload size validation, shortcut selection, picker search filtering, custom emoji token generation, data-channel chunk splitting, readable composer alias rewriting, transfer integrity, saved-library membership, and add/remove library context-menu actions.
|
||||
- `custom-emoji.rules.spec.ts`, `custom-emoji.service.spec.ts`, `custom-emoji-picker.component.spec.ts`
|
||||
- `account-sync.rules.spec.ts` (relayable types)
|
||||
- E2E: `e2e/tests/chat/custom-emoji-user-binding.spec.ts`
|
||||
|
||||
## Security Considerations
|
||||
---
|
||||
|
||||
- Emoji payloads are image-only and size-limited before persistence or broadcast.
|
||||
- Assets sync only to already connected peers; the signaling server does not persist or proxy emoji images.
|
||||
## Security considerations
|
||||
|
||||
- Image-only, size-capped payloads before persist or broadcast.
|
||||
- Assets reach only connected peers (or same-account devices via `account_sync`); server never proxies bytes.
|
||||
|
||||
---
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Usage counts and shortcut ranking are **local only**.
|
||||
- Electron asset table is **shared across OS users** on one desktop install; library keys remain per MetoYou user id.
|
||||
|
||||
---
|
||||
|
||||
## Related features
|
||||
|
||||
- [messaging.md](messaging.md) — tokens in message bodies, proactive push on send
|
||||
- [signaling.md](signaling.md) — `account_sync`
|
||||
- [mobile-capacitor.md](mobile-capacitor.md) — Capacitor SQLite path
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Restructured to match messaging doc style; fixed duplicate sections; Capacitor + account_sync |
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Desktop Local API
|
||||
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
Electron hosts an optional **localhost HTTP API** that exposes read-only access to the local SQLite database, proxies login to allowed signaling servers, and serves bundled Docusaurus documentation offline.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
| Layer | Owns |
|
||||
|-------|------|
|
||||
| Electron `api/router.ts` | HTTP routes, bearer token store, CQRS query dispatch |
|
||||
| Desktop settings | Enable/disable Local API, port, allowed signal servers |
|
||||
| `docs-site` build | Static bundle mounted at `/docusaurus/*` |
|
||||
|
||||
## Trust boundary
|
||||
|
||||
Separate **in-memory bearer tokens** from signaling-server session tokens. Login via Local API issues a local token; read routes require `Authorization: Bearer`. See [authentication.md](authentication.md).
|
||||
|
||||
## Routes (summary)
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| GET | `/api/health` | No | Local API liveness |
|
||||
| GET | `/api/openapi.json`, `/docs`, `/scalar/api-reference.js` | No | API docs (Scalar) |
|
||||
| GET | `/docusaurus/*` | No | In-app documentation site |
|
||||
| POST | `/api/auth/login` | No | Proxy to configured signaling server; returns local bearer |
|
||||
| POST | `/api/auth/logout` | Bearer | Revoke local token |
|
||||
| GET | `/api/profile` | Bearer | Current user profile |
|
||||
| GET | `/api/rooms`, `/api/rooms/{roomId}`, `.../users`, `.../messages`, `.../bans` | Bearer | Read-only room data |
|
||||
| GET | `/api/messages/{messageId}`, `.../reactions`, `.../attachments` | Bearer | Message graph |
|
||||
| GET | `/api/users/{userId}`, `/api/attachments`, `/api/plugin-data` | Bearer | User + plugin data reads |
|
||||
| GET | `/api/meta/{key}` | Bearer | Meta key lookup |
|
||||
|
||||
Database routes return **503** when SQLite is not initialised.
|
||||
|
||||
## IPC
|
||||
|
||||
- `get-local-api-status`, `open-local-api-docs`, `open-docusaurus-docs`
|
||||
|
||||
## Related
|
||||
|
||||
- [authentication.md](authentication.md) — trust table
|
||||
- `electron/CONTEXT.md` — Local API vocabulary
|
||||
- `docs-site/CONTEXT.md` — documentation bundle
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Initial Local API route catalog |
|
||||
@@ -0,0 +1,29 @@
|
||||
# Direct Messaging
|
||||
|
||||
> **Area:** messaging
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
Direct messaging (1:1 and group PMs) is documented in full in **[messaging.md](messaging.md)** — transports, delivery state machine, sync protocol, storage, and security. This file remains as an index entry in [FEATURES.md](../FEATURES.md).
|
||||
|
||||
## Quick reference
|
||||
|
||||
- **Domain:** `toju-app/src/app/domains/direct-message/`
|
||||
- **Entry points:** `DirectMessageService`, `PeerDeliveryService`, `FriendService`
|
||||
- **Persistence:** `metoyou_direct_message_*` (per-user local storage)
|
||||
- **P2P types:** `direct-message`, `direct-message-status`, `direct-message-mutation`, `direct-message-typing`, `direct-message-sync-request`, `direct-message-sync`
|
||||
- **Calls:** `direct-call` shares `PeerDeliveryService` → [voice-webrtc.md](voice-webrtc.md)
|
||||
|
||||
## Related
|
||||
|
||||
- [messaging.md](messaging.md) — full cross-context contract
|
||||
- [signaling.md](signaling.md) — WebSocket relay
|
||||
- Domain README: [`toju-app/src/app/domains/direct-message/README.md`](../../toju-app/src/app/domains/direct-message/README.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Slimmed to index; comprehensive content moved to messaging.md |
|
||||
@@ -0,0 +1,54 @@
|
||||
# Game Activity
|
||||
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
"Now playing" game detection: Electron foreground-window/process heuristics, RAWG metadata match via signaling server, and P2P `game-activity` broadcast to peers. Shown on profile cards and room sidebars.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
| Layer | Owns |
|
||||
|-------|------|
|
||||
| `game-activity` domain | Scan loop, confidence scoring, P2P broadcast, user store updates |
|
||||
| Electron IPC | `get-running-process-names`, `get-active-game-candidate` |
|
||||
| Signaling server | `POST /api/games/match` (RAWG proxy + miss cache) |
|
||||
| P2P | `game-activity` data-channel event |
|
||||
|
||||
## Server API
|
||||
|
||||
### `POST /api/games/match`
|
||||
|
||||
- **Auth:** Public
|
||||
- **Body:** `{ processNames: string[], candidates?: { processName, score }[] }` (bounded list sizes)
|
||||
- **Response:** `{ game: MatchedGame | null }` — RAWG-backed title, cover art, store links
|
||||
|
||||
Misses cached in server SQLite (`GameMatchMiss`) to limit API calls.
|
||||
|
||||
## Client detection
|
||||
|
||||
- Periodic scan (default 10 s, configurable 5–60 s in localStorage `metoyou_game_scan_interval_ms`).
|
||||
- Ignores launcher/helper processes via `IGNORED_PROCESS_NAMES` and regex patterns.
|
||||
- **Electron:** suppresses scan when MetoYou window is focused; prefers foreground-window candidate from `get-active-game-candidate`.
|
||||
- **Browser/Capacitor:** no process scan — activity only from P2P peers.
|
||||
|
||||
## P2P event
|
||||
|
||||
```json
|
||||
{ "type": "game-activity", "activity": { "game", "startedAt", "processName", ... } }
|
||||
```
|
||||
|
||||
Peers merge into `User.gameActivity` in NgRx store.
|
||||
|
||||
## Related
|
||||
|
||||
- [signaling.md](signaling.md) — not WS-relayed
|
||||
- [server-directory.md](server-directory.md) — API base URL for match endpoint
|
||||
- Domain README: [`toju-app/src/app/domains/game-activity/README.md`](../../toju-app/src/app/domains/game-activity/README.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Initial cross-context game-activity contract |
|
||||
@@ -0,0 +1,65 @@
|
||||
# Invites & Join Requests
|
||||
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
Invite links and join-request approval let users join private or moderated chat-servers without public listing. Spans signaling **server** REST + HTML invite pages and the product **client** `server-directory` invite feature.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Server: create time-limited invites, resolve invite metadata, record join requests, notify requesters on moderation decisions.
|
||||
- Client: create/copy invite links, render invite landing UX, call join API with invite codes/passwords.
|
||||
- It does NOT own: WebSocket room membership (`join_server` after REST join succeeds).
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **Invite:** opaque id mapping to a server; may expire.
|
||||
- **Join request:** pending membership when server requires approval.
|
||||
- **request_update:** server-pushed WebSocket notification when a moderator approves/denies.
|
||||
|
||||
## REST API
|
||||
|
||||
### Invites
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| POST | `/api/servers/:id/invites` | Bearer | Create invite (moderator) |
|
||||
| GET | `/api/invites/:id` | Public | Resolve invite metadata + server card |
|
||||
| GET | `/invite/:id` | Public | HTML invite landing page (browser) |
|
||||
|
||||
### Join
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| POST | `/api/servers/:id/join` | Bearer | Join with password, invite id, or public access; may create join request |
|
||||
|
||||
### Join requests (moderation)
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| GET | `/api/servers/:id/requests` | Bearer (`manageServer`) | List pending requests |
|
||||
| PUT | `/api/requests/:id` | Bearer (`manageServer`) | Approve or deny; body `{ status }` |
|
||||
|
||||
`PUT /api/requests/:id` validates optional `ownerId` matches authenticated user, checks `manageServer` permission, updates status, and sends `notifyUser(request.userId, { type: 'request_update', request })`.
|
||||
|
||||
## Client flow
|
||||
|
||||
1. Moderator creates invite via `ServerDirectoryFacade.createInvite()`.
|
||||
2. Recipient opens `/invite/:id` or deep link; client resolves `GET /api/invites/:id`.
|
||||
3. Authenticated user calls `POST /api/servers/:id/join` with invite payload.
|
||||
4. On approval-required servers, user waits for `request_update` or polls requests list (moderator UI).
|
||||
|
||||
## Related
|
||||
|
||||
- [server-directory.md](server-directory.md) — join/leave REST
|
||||
- [authentication.md](authentication.md) — bearer on mutations
|
||||
- [signaling.md](signaling.md) — `join_server` after join
|
||||
- Domain README: [`toju-app/src/app/domains/server-directory/README.md`](../../toju-app/src/app/domains/server-directory/README.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Initial cross-context invite/join-request contract |
|
||||
@@ -0,0 +1,44 @@
|
||||
# Klipy GIFs
|
||||
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
GIF search in chat and DM composers via a **Klipy API proxy** on the signaling server. API keys stay server-side; clients call same-origin routes on the active signal server.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Server: proxy/search Klipy API (`variables.json` `klipyApiKey`).
|
||||
- Client `chat` domain: `KlipyService`, composer picker; DMs reuse the same integration.
|
||||
|
||||
## API
|
||||
|
||||
### `GET /api/klipy/config`
|
||||
|
||||
- **Auth:** Public
|
||||
- **Response:** `{ enabled: boolean }` — `enabled` when server has a configured API key
|
||||
|
||||
### `GET /api/klipy/gifs`
|
||||
|
||||
- **Auth:** Public
|
||||
- **Query:** `q` (search), `page`, `per_page` (default 24, max 50)
|
||||
- **Response:** Normalised `{ gifs: [{ id, slug, title, url, previewUrl, width, height }], hasNext }`
|
||||
- **Upstream:** `https://api.klipy.com/api/v1` with 8 s timeout
|
||||
|
||||
## Client behavior
|
||||
|
||||
- GIF picker visibility is resolved against the **current chat-server's** signal server (not a global offline endpoint) so KLIPY availability matches the room's backend.
|
||||
- Selected GIFs send as markdown image messages; rendering uses [link-preview-media-proxy.md](link-preview-media-proxy.md) image proxy when needed.
|
||||
|
||||
## Related
|
||||
|
||||
- [server-directory.md](server-directory.md) — per-room signal server for config
|
||||
- [direct-messaging.md](direct-messaging.md) — DM composer reuse
|
||||
- Domain README: [`toju-app/src/app/domains/chat/README.md`](../../toju-app/src/app/domains/chat/README.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Initial Klipy proxy contract |
|
||||
@@ -0,0 +1,46 @@
|
||||
# Link Preview & Media Proxy
|
||||
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
The signaling server fetches untrusted URLs on behalf of clients for **link embed previews** and **image proxying**, with SSRF guards. Chat and DM composers render embeds using these endpoints.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Server: outbound fetch with host validation, caching, size limits.
|
||||
- Client `chat` domain: request metadata when messages contain URLs; render cards in message list.
|
||||
- It does NOT store embeds long-term on the server beyond in-memory cache.
|
||||
|
||||
## API
|
||||
|
||||
### `GET /api/link-metadata`
|
||||
|
||||
- **Auth:** Public
|
||||
- **Query:** `url` (http/https)
|
||||
- **Response:** `{ title?, description?, imageUrl?, siteName? }`
|
||||
- **Guards:** `resolveAndValidateHost` + `safeFetch`; 8 s timeout; HTML capped at 512 KB; in-memory cache sized by `variables.json` link-preview config
|
||||
|
||||
### `GET /api/image-proxy`
|
||||
|
||||
- **Auth:** Public
|
||||
- **Query:** `url` (http/https)
|
||||
- **Response:** Raw image bytes (`Content-Type` from origin)
|
||||
- **Limits:** image/* only; max 8 MB; 8 s timeout; SSRF validation
|
||||
- **Cache:** `Cache-Control: public, max-age=3600`
|
||||
|
||||
## Client usage
|
||||
|
||||
Message markdown / link-embed pipeline calls link-metadata for unfurling; proxied images load through `/api/image-proxy` when direct fetch would fail (CORS, mixed content).
|
||||
|
||||
## Related
|
||||
|
||||
- [server-directory.md](server-directory.md) — requests use active server's API base
|
||||
- Domain README: [`toju-app/src/app/domains/chat/README.md`](../../toju-app/src/app/domains/chat/README.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Initial link preview / image proxy contract |
|
||||
@@ -1,6 +1,14 @@
|
||||
# Message Integrity
|
||||
|
||||
Signed, append-only **message revisions** give P2P chat a verifiable history without central message storage. The materialized `Message` row in local SQLite/IDB is a cache; peers converge via inventory snapshots and revision events.
|
||||
> **Area:** messaging
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
Signed, append-only **message revisions** give P2P chat a verifiable history without central message storage. The materialized `Message` row in local SQLite/IDB is a cache; peers converge via inventory snapshots (`revision`, `headHash`) and `message-revision` events.
|
||||
|
||||
Parent transport and sync context: [messaging.md](messaging.md).
|
||||
|
||||
## Responsibilities
|
||||
|
||||
@@ -15,7 +23,7 @@ Signed, append-only **message revisions** give P2P chat a verifiable history wit
|
||||
| --- | --- |
|
||||
| Product client (`toju-app`) | Revision construction, merge, verification, P2P broadcast, local persistence |
|
||||
| Signaling server (`server`) | `PUT /api/users/me/signing-key`, `GET /api/users/:id/signing-public-key` — key directory only, no message storage |
|
||||
| Electron / mobile persistence | `revision` + `headHash` on message rows; revision audit log (IDB store / SQLite meta) |
|
||||
| Electron / mobile persistence | `revision` + `headHash` on message rows; revision audit log in IDB store (browser), SQLite `meta` table (Electron **and Capacitor** — keys `message-revision:<messageId>:<revision>`) |
|
||||
|
||||
Plugin API messages may emit unsigned revisions (`plugin-edit` / `plugin-delete`) when the actor is a synthetic plugin user.
|
||||
|
||||
@@ -47,7 +55,23 @@ Plugin API messages may emit unsigned revisions (`plugin-edit` / `plugin-delete`
|
||||
| `PUT` | `/api/users/me/signing-key` | Bearer | `{ publicKeyJwk }` — stores Ed25519 public JWK on the user row |
|
||||
| `GET` | `/api/users/:id/signing-public-key` | Public | `{ publicKeyJwk }` — used by peers to verify signatures |
|
||||
|
||||
Registration runs automatically after login/register via `AuthenticationService`.
|
||||
Registration runs automatically after **home** login/register via `AuthenticationService` — see [authentication.md](authentication.md) for foreign-server scope.
|
||||
|
||||
## Multi-device relay (`account_sync`)
|
||||
|
||||
`message-revision` chat events are relayable to sibling connections via WebSocket `account_sync` (alongside legacy `chat-message` paths documented in [authentication.md](authentication.md)). Inventory convergence still prefers P2P data-channel sync when peers are connected.
|
||||
|
||||
## Related
|
||||
|
||||
- [authentication.md](authentication.md) — signing-key registration, `account_sync` chat batches
|
||||
- [signaling.md](signaling.md) — `account_sync` envelope
|
||||
- [mobile-capacitor.md](mobile-capacitor.md) — Capacitor `meta` revision keys
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Capacitor meta persistence; account_sync cross-ref; signing registration scope |
|
||||
|
||||
## Degraded-mode behavior
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# Messaging
|
||||
|
||||
> **Area:** messaging
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-13
|
||||
|
||||
## Overview
|
||||
|
||||
Messaging in MetoYou covers two transports that share inventory-sync concepts and (for DMs) a monotonic delivery state machine. **Server-channel chat** is broadcast by the signaling server over WebSocket (`chat_message`) as a narrow fallback when P2P data channels are down — the server does not persist message bodies. **Direct messages** (1:1 and group DMs) are primarily peer-to-peer over the WebRTC ordered data channel, with WebSocket signaling relay when no channel is open and an offline queue when neither path succeeds.
|
||||
|
||||
On both transports the client maintains local history (Electron SQLite / browser IndexedDB for server channels; user-scoped `localStorage` for DMs) and a **chunked inventory-sync protocol** so peers reconcile missing rows without flooding the link.
|
||||
|
||||
This document is the cross-context contract: envelope names, sync protocol, delivery states, edit/delete rules, and storage boundaries. Internal NgRx orchestration lives in [`toju-app/src/app/domains/chat/README.md`](../../toju-app/src/app/domains/chat/README.md) and [`toju-app/src/app/domains/direct-message/README.md`](../../toju-app/src/app/domains/direct-message/README.md). WebSocket relay rules: [signaling.md](signaling.md). Signed revision chains: [message-integrity.md](message-integrity.md).
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Send server-channel chat over WebSocket fallback (`chat_message`) and primarily over P2P (`chat-message`, `edit-message`, `delete-message`, `message-revision`).
|
||||
- Send, edit, delete, and react in direct messages over the data channel with signaling fallback.
|
||||
- Carry typing indicators: server channels (`typing` → `user_typing`) and DMs (`direct-message-typing`).
|
||||
- Reconcile peer history via the inventory protocol (`chat-inventory` / `chat-sync-batch`; DM `direct-message-sync`).
|
||||
- Drive a monotonic DM delivery state machine: `QUEUED → SENT → DELIVERED → ACKNOWLEDGED`.
|
||||
- Relay multi-device chat via `account_sync` (`chat-message`, `message-revision`, `chat-sync-batch`).
|
||||
|
||||
This area does **not** own:
|
||||
|
||||
- Attachment payloads or chunked file transfer → [attachments.md](attachments.md).
|
||||
- WebRTC session setup and data-channel lifecycle → [voice-webrtc.md](voice-webrtc.md).
|
||||
- Write permission resolution (`writeMessages`, `manageMessages`, bans) → `toju-app/src/app/domains/access-control/README.md`.
|
||||
- Full WebSocket envelope catalog (identity, voice, plugins) → [signaling.md](signaling.md).
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **Server-channel message** — room-scoped text in a saved chat-server. Primary path: P2P `chat-message` on the data channel. Fallback: server broadcasts `chat_message` to other connections in the room.
|
||||
- **Direct message** — 1:1 or group PM. Persisted per user under `metoyou_direct_message_*` keys (domain-owned storage, not the global messages CQRS table).
|
||||
- **Conversation** — DM thread (`direct` or `group`). Upgrading a 1:1 call to a group creates a **new** group conversation; the original 1:1 history is not copied.
|
||||
- **Inventory event** — `chat-inventory` (P2P): sender announces message ids plus integrity fields (`ts`, `rc`, `ac`, `revision`, `headHash`); receiver requests missing or stale ids.
|
||||
- **Sync batch** — `chat-sync-batch`: chunked response, **200 messages per envelope** (`CHUNK_SIZE` in `message-sync.rules.ts`).
|
||||
- **Delivery state** — DM-only enum: `QUEUED (0) → SENT (1) → DELIVERED (2) → ACKNOWLEDGED (3)`. Advanced only via `advanceDirectMessageStatus` (never backwards).
|
||||
- **Peer delivery** — `PeerDeliveryService` tries data channel, then signaling forward, then offline queue.
|
||||
|
||||
---
|
||||
|
||||
## Transports
|
||||
|
||||
### Server-channel chat
|
||||
|
||||
**P2P (primary):** `chat-message`, `edit-message`, `delete-message`, `message-revision`, reactions, and inventory events on the ordered data channel. See [message-integrity.md](message-integrity.md) for dual-emit revision behavior.
|
||||
|
||||
**WebSocket (fallback):** Client sends `chat_message`; `handleChatMessage` (`server/src/websocket/handler.ts`) broadcasts to other connections in the room. The server does **not** handle `edit_message` or `delete_message` on the wire — edits and deletes are P2P (and `account_sync` for sibling devices).
|
||||
|
||||
**Typing:** Client sends `typing`; server broadcasts `user_typing` (transient, no persistence).
|
||||
|
||||
**Multi-device:** Sibling tabs receive live chat via `account_sync` payloads (`chat-message`, `message-revision`, `chat-sync-batch`). See [authentication.md](authentication.md).
|
||||
|
||||
### Direct messages
|
||||
|
||||
**P2P (primary):** Events on the shared ordered data channel (same peer connections as voice/chat).
|
||||
|
||||
**WebSocket (fallback):** `PeerDeliveryService.sendViaSignaling` forwards these types to `targetUserId` without requiring shared server membership:
|
||||
|
||||
| type | Purpose |
|
||||
|------|---------|
|
||||
| `direct-message` | New message |
|
||||
| `direct-message-status` | Delivery / ack |
|
||||
| `direct-message-mutation` | Edit, delete, reactions |
|
||||
| `direct-message-typing` | Typing indicator |
|
||||
| `direct-message-sync-request` | Request snapshot |
|
||||
| `direct-message-sync` | Bounded history merge |
|
||||
|
||||
**Offline queue:** When both paths fail, `OfflineMessageQueueService` retains message ids; replay runs on `peerConnected$` / `networkRestored$` (no scheduled retry timer).
|
||||
|
||||
### Storage
|
||||
|
||||
| Data | Where |
|
||||
|------|--------|
|
||||
| Server-channel messages | `DatabaseService` → Electron SQLite or browser IndexedDB (`messages` store) |
|
||||
| Direct messages | `metoyou_direct_message_*` via direct-message repositories |
|
||||
| Signaling server | **No message bytes** — broadcast/relay only |
|
||||
|
||||
---
|
||||
|
||||
## Inventory / sync protocol
|
||||
|
||||
Shared shapes in `toju-app/src/app/shared-kernel/chat-events.ts`:
|
||||
|
||||
| Event | Role |
|
||||
|-------|------|
|
||||
| `chat-inventory-request` | Ask peer for inventory |
|
||||
| `chat-inventory` | Announce ids + integrity snapshots |
|
||||
| `chat-sync-request` | Request specific missing ids |
|
||||
| `chat-sync-batch` | Up to **200** messages per envelope |
|
||||
| `direct-message-sync-request` / `direct-message-sync` | DM-scoped snapshot merge |
|
||||
|
||||
Rules (`message-sync.rules.ts`, `message-integrity.rules.ts`):
|
||||
|
||||
- Merges are **additive** — sparser peers never wipe richer local history.
|
||||
- `findMissingIds` compares remote inventory to local `revision` / `headHash` (and legacy `ts` / `rc` / `ac`).
|
||||
- `INVENTORY_LIMIT` = `FULL_SYNC_LIMIT` = **20_000** (2026-07-14, RAM investigation; previously 1_000_000). Building an inventory or full-sync batch loads full message rows into memory, so the ceiling must stay bounded. Only the most recent 20k messages per room are reconciled peer-to-peer; older messages stay local-only. `ACCOUNT_SYNC_MESSAGE_LIMIT` follows `FULL_SYNC_LIMIT`.
|
||||
- Sync polling: 10 s when catching up, 15 min after a clean cycle (`SYNC_POLL_FAST_MS` / `SYNC_POLL_SLOW_MS`).
|
||||
- NgRx store retention: on room switch, inactive rooms are pruned to the most recent `CACHED_INACTIVE_ROOM_MESSAGE_LIMIT` = **100** messages each (`messages.reducer.ts`), keeping return-visit rendering instant while bounding store growth across many rooms. The active room is never pruned; the local DB keeps full history.
|
||||
|
||||
---
|
||||
|
||||
## Delivery state machine (DMs only)
|
||||
|
||||
| Value | Numeric | Meaning |
|
||||
|-------|---------|---------|
|
||||
| `QUEUED` | 0 | Composed locally; no successful send yet |
|
||||
| `SENT` | 1 | Data channel or signaling forward accepted the payload |
|
||||
| `DELIVERED` | 2 | At least one recipient acknowledged receipt |
|
||||
| `ACKNOWLEDGED` | 3 | Full recipient set acknowledged (1:1: the peer; group: every participant) |
|
||||
|
||||
`advanceDirectMessageStatus` only moves forward (`direct-message.logic.ts`). Server-channel messages have no application-level delivery enum; the UI treats them as sent once the transport accepts the event.
|
||||
|
||||
---
|
||||
|
||||
## Edit and delete
|
||||
|
||||
**Server channels:** Outgoing edits check `canEditMessage(message, userId)` before broadcast. Incoming P2P `edit-message` / `delete-message` merge via NgRx handlers; signed paths prefer `message-revision` when integrity is enabled.
|
||||
|
||||
**DMs:** `direct-message-mutation` with types `edit`, `delete`, `reaction-add`, `reaction-remove`. `applyMutation` in `DirectMessageService` updates by `messageId` but **does not verify** the mutator is the original author — a non-cooperating peer could mutate another user's row. Server chat enforces authorship on **outgoing** edits only.
|
||||
|
||||
Deletes keep tombstone semantics (`isDeleted`, empty `content`) so inventory sync can converge.
|
||||
|
||||
---
|
||||
|
||||
## Business rules and invariants
|
||||
|
||||
- The signaling server is **not authoritative** for message content — it relays `chat_message` and DM types opaquely.
|
||||
- DM events are **ignored** unless the local user is in `recipients` / `participants` or already has the conversation locally.
|
||||
- Recipient matching (DM **and** `direct-call`) must accept **every local identity alias** — home id, entity id, peer id, and each provisioned signal-server actor id from `SignalServerCredentialStoreService` — because senders who met the recipient on a foreign signal server address them by the provisioned actor id (`direct-message-identity.rules.ts`, `direct-call-participant-identity.rules.ts`).
|
||||
- DM status transitions are **monotonic**.
|
||||
- Inventory merges never downgrade a row with a newer `revision` / `headHash`.
|
||||
- 1:1 → group upgrade **does not copy** private history into the new group thread.
|
||||
- Unread counts are **idempotent by message id** — re-sync does not double-increment.
|
||||
- Incoming DMs raise a system notification via `NotificationsFacade.handleIncomingDirectMessage` (title = sender name; `shouldDeliverDirectMessageNotification` suppresses only when the conversation is on screen in an active window, notifications are disabled, or the user is busy). System messages (e.g. call-started) and deletions never notify. On Capacitor this flows through the same `DesktopNotificationService` → LocalNotifications routing as server chat.
|
||||
|
||||
---
|
||||
|
||||
## Technical implementation
|
||||
|
||||
### Server
|
||||
|
||||
- `server/src/websocket/handler.ts` — `handleChatMessage`, `handleTyping`, DM forward via `forwardRtcMessage` / `DIRECT_SIGNALING_TYPES`.
|
||||
- No message CQRS or entities on the server.
|
||||
|
||||
### Product client
|
||||
|
||||
| Area | Location |
|
||||
|------|----------|
|
||||
| Server chat effects / handlers | `store/messages/`, `domains/chat/` |
|
||||
| DM service / queue | `domains/direct-message/application/services/` |
|
||||
| Sync rules | `domains/chat/domain/rules/message-sync.rules.ts` |
|
||||
| Wire types | `shared-kernel/chat-events.ts`, `direct-message-contracts.ts` |
|
||||
| Account sync relay | `infrastructure/realtime/account-sync/` |
|
||||
|
||||
### Electron
|
||||
|
||||
- Server-channel rows: TypeORM `Message` entity + CQRS `save-message` / `delete-message`.
|
||||
- DMs: renderer `localStorage` repositories (not the main message table).
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: `message-sync.rules.spec.ts`, `message-integrity.rules.spec.ts`, `message.rules.spec.ts`, `direct-message.service.spec.ts`, `direct-message.logic` specs, `messages-incoming.handlers.spec.ts`, `account-sync-chat.helper.spec.ts`.
|
||||
- E2E: `e2e/tests/chat/chat-message-features.spec.ts`, `multi-client-chat-sync.spec.ts`, `dm-flow.spec.ts`, `multi-device-attachment-sharing.spec.ts`, `e2e/tests/voice/dm-header-call-ring.spec.ts` (DM-header call ring, incl. cross-signal actor-id addressing).
|
||||
|
||||
---
|
||||
|
||||
## Performance considerations
|
||||
|
||||
- Sync batches: **200 messages per `chat-sync-batch` envelope**.
|
||||
- `chat_message` broadcast is O(connections in room) per send.
|
||||
- Group DMs: O(recipients) transport attempts per message.
|
||||
|
||||
---
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **No end-to-end encryption** for message bodies. WebRTC data channels use DTLS; signaling fallback is TLS WebSocket; local DBs store plaintext.
|
||||
- **DM `applyMutation` does not verify authorship** on incoming mutations.
|
||||
- **No server-side rate limit** on `chat_message` volume.
|
||||
|
||||
---
|
||||
|
||||
## Known issues and limitations
|
||||
|
||||
- **No server-side chat log** — late joiners depend on peers with local history or `account_sync` from a sibling device.
|
||||
- **DM mutation authorship** not verified on receive.
|
||||
- **Offline queue** replays only on peer connect / network restore events.
|
||||
|
||||
---
|
||||
|
||||
## Related features
|
||||
|
||||
- [signaling.md](signaling.md) — WebSocket relay and ordering invariants
|
||||
- [message-integrity.md](message-integrity.md) — signed revision chains
|
||||
- [attachments.md](attachments.md) — file payloads alongside chat events
|
||||
- [voice-webrtc.md](voice-webrtc.md) — data channel transport
|
||||
- [authentication.md](authentication.md) — `account_sync` multi-device relay
|
||||
- [direct-messaging.md](direct-messaging.md) — short index (defers here)
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-14 | RAM bounds: `INVENTORY_LIMIT`/`FULL_SYNC_LIMIT` lowered to 20k (most recent messages reconcile); NgRx prunes inactive rooms to 100 cached messages on room switch |
|
||||
| 2026-07-13 | Incoming DMs raise system notifications through the notifications domain (previously unread-badge only) |
|
||||
| 2026-07-13 | Recipient matching for DM and `direct-call` events must span all local identity aliases (provisioned actor ids included) |
|
||||
| 2026-07-05 | Initial comprehensive messaging contract (replaces thin direct-messaging summary) |
|
||||
@@ -31,10 +31,6 @@ npm run cap:sync
|
||||
npm run cap:open:android
|
||||
npm run cap:open:ios
|
||||
|
||||
### Linux: Android Studio path
|
||||
|
||||
Capacitor defaults to `/usr/local/android-studio/bin/studio.sh`. If Android Studio is installed elsewhere (common with **Flatpak** from Flathub), `npm run cap:open:android` uses `tools/resolve-android-studio-path.js` to locate `studio.sh` (Flatpak `active` symlink, Toolbox, snap, `/opt`, etc.). Override anytime with `CAPACITOR_ANDROID_STUDIO_PATH`.
|
||||
|
||||
# Convenience (build + sync + open)
|
||||
npm run cap:build:android
|
||||
npm run cap:build:ios
|
||||
@@ -44,6 +40,10 @@ npm run cap:apk:android
|
||||
# → toju-app/android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### Linux: Android Studio path
|
||||
|
||||
Capacitor defaults to `/usr/local/android-studio/bin/studio.sh`. If Android Studio is installed elsewhere (common with **Flatpak** from Flathub), `npm run cap:open:android` uses `tools/resolve-android-studio-path.js` to locate `studio.sh` (Flatpak `active` symlink, Toolbox, snap, `/opt`, etc.). Override anytime with `CAPACITOR_ANDROID_STUDIO_PATH`.
|
||||
|
||||
Config: `toju-app/capacitor.config.ts` (`webDir: ../dist/client/browser`).
|
||||
|
||||
### CI (Gitea)
|
||||
@@ -58,18 +58,42 @@ Optional `google-services.json` is not injected in CI; push registration in arti
|
||||
|
||||
After dependency or plugin changes, run `npm run build:prod && npm run cap:sync` so native projects register `@capacitor/app`, `@capacitor-community/sqlite`, `@capawesome/capacitor-app-update`, push plugins, and `MetoyouMobile`.
|
||||
|
||||
## App icon & splash (Android brand assets)
|
||||
|
||||
The Capacitor shell must ship the Toju brand mark, not the stock Ionic/Capacitor placeholder. Brand resources are generated from `images/icon-new-rounded.png` (circular cat-on-purple disc) into `toju-app/android/app/src/main/res/`:
|
||||
|
||||
```bash
|
||||
npm run cap:assets:android # → tools/generate-android-app-icons.mjs (uses sharp)
|
||||
```
|
||||
|
||||
This produces, for every density (`mdpi … xxxhdpi`):
|
||||
|
||||
- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` — legacy launcher bitmaps (the brand disc inset to the adaptive-icon safe zone so circular masks do not clip the cat face).
|
||||
- `mipmap-*/ic_launcher_foreground.png` — adaptive foreground centred at **66/108** of the 108dp canvas (Android safe zone); the adaptive layers in `mipmap-anydpi-v26/ic_launcher*.xml` reference `@mipmap/ic_launcher_foreground` with `@color/ic_launcher_background` brand purple behind it.
|
||||
- `values/ic_launcher_background.xml` — adaptive background colour set to the **brand purple `#4A217A`**, not stock white.
|
||||
- `drawable*/splash.png` (port + land per density, plus the base) — brand mark centred at **32%** of the shorter splash edge on a purple field (down from 40% so the cat face is not cropped on launch).
|
||||
|
||||
Invariants are encoded in `toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts` (required file set, brand background colour, and the SHA-256 of every stock Capacitor placeholder that must never reappear). Coverage:
|
||||
|
||||
- Unit: `mobile-android-launcher-icon.rules.spec.ts` — asserts every density is present, no resource matches a stock placeholder hash, and the adaptive background is the brand purple.
|
||||
- E2E: `e2e/tests/mobile/android-app-icon.spec.ts` — same contract plus pixel checks (launcher ring is purple, centre is the white cat; splash corner is purple, centre is the cat). Deterministic; no emulator.
|
||||
|
||||
Re-run `npm run cap:assets:android` whenever `images/icon-new-rounded.png` changes; `npm run cap:sync` is **not** needed (resources live in the native project, not `webDir`).
|
||||
|
||||
## Feature status
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Push/local notifications | **Working (partial)** | Local notifications always available; remote push (FCM/APNs) registers only when Firebase/APNs is configured — app starts normally without `google-services.json` |
|
||||
| Chat message notifications | **Working** | `DesktopNotificationService` routes to `MobileNotificationsService.showMessage()` on Capacitor (LocalNotifications channel `toju-messages`); web `Notification` API is never used on native shells |
|
||||
| Server push dispatch | **Working (configured)** | Tokens persist in server SQLite; outbound FCM/APNs via env credentials |
|
||||
| In-call notifications | **Working (Capacitor)** | Persistent notification with answer/mute/hang-up actions |
|
||||
| Stream pop-out (PiP) | **Working (partial)** | Document PiP when WebView supports it; Android native PiP fallback via `MetoyouMobile` plugin |
|
||||
| Background voice | **Working (partial)** | Android foreground service; iOS `UIBackgroundModes` audio + CallKit active-call bridge |
|
||||
| iOS CallKit | **Working (partial)** | `MetoyouMobile.startCallKitSession` reports active calls; requires Xcode target wiring after `cap:sync` |
|
||||
| Screensharing | **Limited** | Disabled on iOS WebView; Android `getDisplayMedia` may work |
|
||||
| Screensharing | **Hidden on native mobile** | `getDisplayMedia` is unavailable in mobile WebViews; all screen-share buttons (private call, voice controls, floating controls, voice workspace) are gated behind `!viewport.isMobile() && !MobilePlatformService.isNativeMobile()` |
|
||||
| Composer attachments | **Working** | Mobile attachment button + hidden file input |
|
||||
| Attachment download/export | **Working** | `AttachmentDownloadService` delegates to `CapacitorAttachmentExportService` on native shells: copies disk-backed files (or fetches the object URL) into the public `Documents` directory with a timestamped name; anchor `download` links do nothing in the Android WebView |
|
||||
| Camera sharing | **Working** | Existing `getUserMedia` camera path in WebRTC stack |
|
||||
| Speakerphone | **Working (partial)** | Android `AudioManager` via `MetoyouMobile`; iOS `@capgo/capacitor-audio-session`; direct-call speaker toggle on native mobile |
|
||||
| Local DB (SQLite) | **Working** | `DatabaseService` routes Capacitor shells to `CapacitorDatabaseService` (native SQLite CRUD) |
|
||||
@@ -81,7 +105,7 @@ After dependency or plugin changes, run `npm run build:prod && npm run cap:sync`
|
||||
- **iOS CallKit:** Plugin Swift source ships in `ios/App/App/MetoyouMobilePlugin.swift`; add it to the Xcode target if not auto-linked. Incoming-call UI is not fully bridged to WebRTC answer/hang-up yet.
|
||||
- **iOS screenshare:** `getDisplayMedia` is not available in WKWebView.
|
||||
- **Android PiP:** Native PiP enters activity-level PiP; WebView video may not always render inside PiP on all OEM WebViews.
|
||||
- **Production discovery:** `signal.toju.app` may not expose `/api/servers/featured` or `/trending`; client skips those calls for known hosts.
|
||||
- **Legacy discovery endpoints:** Older signal servers may not expose `/api/servers/featured` or `/trending` (they resolve as `/servers/:id` and return 404). The client still calls those routes on every online endpoint and **falls back per-endpoint to `GET /api/servers`** when 404 is returned — see [server-discovery.md](server-discovery.md).
|
||||
- **Push delivery:** Requires FCM service account and APNs key configuration on the signaling server.
|
||||
|
||||
## Push notification setup (FCM / APNs)
|
||||
@@ -111,8 +135,13 @@ Declared in `toju-app/android/app/src/main/AndroidManifest.xml`:
|
||||
| `BLUETOOTH_CONNECT` | Bluetooth headset routing during calls (Android 12+) |
|
||||
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
|
||||
| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session |
|
||||
| `READ_EXTERNAL_STORAGE` (maxSdk 32) / `WRITE_EXTERNAL_STORAGE` (maxSdk 29) | Attachment export to public `Documents` on Android 10 and below |
|
||||
|
||||
Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells.
|
||||
Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells. If the native plugin is unavailable or the bridge call fails, capture preflight defers to the WebView `getUserMedia` permission flow instead of aborting voice/camera joins. Preflight only blocks capture on an explicit native `denied` state (`mobile-media-permission.rules.ts`); a `prompt` state is deferred to the WebView so the user still gets the permission dialog.
|
||||
|
||||
Join and capture failures are surfaced in the UI instead of failing silently: `DirectCallService.joinCall` sets a `joinError` signal (`call.errors.*` i18n keys for signaling, capture-unsupported, mic permission, and mic unavailable cases), and the private-call and voice-controls components surface camera errors the same way.
|
||||
|
||||
On Capacitor startup, `MobileRuntimePermissionsService` (via `MobileAppLifecycleService.initialize()`) proactively prompts for microphone, camera, local-notification, and push-notification runtime permissions so Android 13+ shells do not keep every permission in the "Not allowed" state until the user joins voice or receives a call.
|
||||
|
||||
### iOS (APNs)
|
||||
|
||||
@@ -141,16 +170,19 @@ Tokens persist in server SQLite (`device_tokens` table). Outbound push uses repo
|
||||
| `APNS_BUNDLE_ID` | Defaults to `com.metoyou.app` |
|
||||
| `APNS_USE_SANDBOX` | `true` for development builds |
|
||||
|
||||
Manual dispatch (ops/testing):
|
||||
Manual dispatch (ops/testing). Requires `Authorization: Bearer`; `:userId` in the path **must match** the authenticated user (`403` otherwise):
|
||||
|
||||
```http
|
||||
POST /api/users/device-tokens/:userId/dispatch
|
||||
Authorization: Bearer <token>
|
||||
{ "title": "Incoming call", "body": "Alice is calling" }
|
||||
```
|
||||
|
||||
`POST /api/users/device-tokens` and `GET /api/users/device-tokens/:userId` apply the same rule: body/param `userId` must equal the bearer identity.
|
||||
|
||||
## Android foreground service
|
||||
|
||||
`VoiceCallForegroundService` starts when `MobileCallSessionService` begins an active call. Required manifest permissions:
|
||||
`VoiceCallForegroundService` starts when `MobileCallSessionService` begins an active call. The voice-channel path also starts/stops it directly: `MediaManager.enableVoice()` / `disableVoice()` call `startMobileVoiceForegroundSession()` / `stopMobileVoiceForegroundSession()` (`infrastructure/mobile/logic/mobile-voice-foreground-session.ts`) so channel voice keeps the mic alive when the app backgrounds. Required manifest permissions:
|
||||
|
||||
- `FOREGROUND_SERVICE`
|
||||
- `FOREGROUND_SERVICE_MICROPHONE`
|
||||
@@ -169,17 +201,21 @@ The service shows a low-importance ongoing notification while a call is active.
|
||||
- Routing: `infrastructure/persistence/database-backend.rules.ts` — Capacitor uses SQLite, not IndexedDB.
|
||||
- Per-user database files: `metoyou__<userId>` via `mobile-sqlite-database-name.rules.ts`.
|
||||
- First launch runs DDL migrations stored in the `meta` table. Schema init failures are cached per database file so the client does not retry in a loop.
|
||||
- **Custom emoji assets** persist in the `custom_emojis` table (`CapacitorDatabaseService.saveCustomEmoji` / `getCustomEmojis` / `deleteCustomEmoji`).
|
||||
- **Message revisions** persist in `meta` under keys `message-revision:<messageId>:<revision>` (JSON payload). See [message-integrity.md](message-integrity.md) and [custom-emoji.md](custom-emoji.md).
|
||||
|
||||
## Capacitor plugin loading
|
||||
|
||||
- `infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts` uses **static** `@capacitor/*` imports and `Capacitor.isPluginAvailable()` before returning a plugin. Do not `import()` plugin modules dynamically or `await` plugin objects (Capacitor proxies expose a throwing `.then()` stub).
|
||||
- `infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts` loads `@capacitor/*` modules via **dynamic `import()`** only when `isCapacitorNativeRuntime()` is true, and checks `Capacitor.isPluginAvailable()` before returning a plugin. Electron and browser shells never evaluate these imports at startup.
|
||||
- Do not `await` a Capacitor plugin proxy object directly — Capacitor proxies expose a throwing `.then()` stub; always call methods on the resolved plugin instance.
|
||||
- After adding or upgrading Capacitor plugins, run `npm run build:prod && npm run cap:sync` so Android/iOS native projects register `App`, `AppUpdate`, `LocalNotifications`, push, and SQLite.
|
||||
|
||||
## Safe area (Android)
|
||||
|
||||
- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads.
|
||||
- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads. `MobileAppLifecycleService` calls `syncMobileSafeAreaInsets()` after Capacitor boot so Android SystemBars recomputes inset variables once the SPA is ready.
|
||||
- `capacitor.config.ts` sets `plugins.SystemBars.insetsHandling: 'css'` so Android WebView versions that mis-report `env(safe-area-inset-*)` still receive correct insets.
|
||||
- Global `styles.scss` applies inset padding on `html` (with `env()` fallback) and sizes `app-root` to `height: 100%` so content stays below the status bar and above the navigation bar in edge-to-edge mode.
|
||||
- Global `styles.scss` defines `metoyou-safe-area-shell` (mobile app shell padding), `metoyou-fixed-safe-viewport` (full-screen modals/backdrops), and `metoyou-fixed-safe-bottom-sheet` (bottom sheets and CDK profile-card panels). These read `--safe-area-inset-*` with `env()` fallback so routed pages, settings, context menus, and profile cards stay below the status bar and above the navigation bar.
|
||||
- Android `styles.xml` uses transparent status/navigation bars and `windowLayoutInDisplayCutoutMode=shortEdges` so Capacitor can draw edge-to-edge and report accurate insets.
|
||||
|
||||
## Self-hosted HTTPS signal servers (Android)
|
||||
|
||||
@@ -231,6 +267,7 @@ Network security configs:
|
||||
- `MobileCallSessionService` — CallKit + foreground service + in-call notifications.
|
||||
- `App` bootstrap — initializes mobile persistence, lifecycle, app-update polling, call-session, and push registration wiring.
|
||||
- `MobileAppUpdateService` — periodic Play Store / App Store checks (30 min) and settings UI actions; mirrors Electron `DesktopAppUpdateService` polling but uses native store APIs instead of release manifests.
|
||||
- Settings → **Data** on Capacitor shells shows the private app-data root and **Erase user data** (`LocalUserDataService` clears SQLite, Capacitor attachment files, auth tokens, and `metoyou_*` localStorage keys, then logs out).
|
||||
|
||||
## Phase 3 completion notes
|
||||
|
||||
@@ -241,10 +278,16 @@ Phase 3 delivered:
|
||||
3. iOS CallKit bridge (partial) via `MetoyouMobile` plugin and `MobileCallKitService`.
|
||||
4. Android Firebase Gradle wiring with `google-services.json.example` (real file gitignored).
|
||||
5. Capacitor plugin availability checks to avoid hard failures when plugins are missing pre-sync.
|
||||
6. Discovery endpoint skip for production signal hosts without featured/trending routes.
|
||||
6. Discovery 404 fallback to public server listing on legacy signal hosts (see [server-discovery.md](server-discovery.md)).
|
||||
|
||||
Remaining work:
|
||||
|
||||
- Wire CallKit answer/end actions back into `DirectCallService`.
|
||||
- Migrate legacy IndexedDB mobile data into SQLite where needed.
|
||||
- Deploy featured/trending routes to production signal servers or add capability negotiation in health checks.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-13 | Chat notifications routed to LocalNotifications (`toju-messages` channel + `ic_stat_metoyou` status icon); capture preflight blocks only on native `denied`; call join/camera errors surfaced via `call.errors.*`; voice channels start the foreground service; screen share hidden on native mobile; attachment export to `Documents`; full-screen overlays use `metoyou-fixed-safe-viewport` |
|
||||
| 2026-07-05 | Corrected discovery fallback (not host skip), plugin-loader dynamic imports, markdown fence; added Capacitor custom-emoji/revision persistence and dispatch auth rules |
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Plugins
|
||||
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
Client-only plugin runtime with server-stored **metadata** (install requirements, event definitions) and Electron-local **plugin data** persistence. Plugins extend chat slash commands, toolbar actions, DOM mounts, and a P2P message bus — they never execute on the signaling server.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
| Layer | Owns |
|
||||
|-------|------|
|
||||
| Product client (`plugins` domain) | Manifest validation, load order, `PluginHostService`, UI registry, store installs |
|
||||
| Electron | Local manifest discovery (`plugins/`, `plugin-bundles/`), `plugin_data` CQRS table, path jail |
|
||||
| Signaling server | Requirement/event metadata REST + `plugin_event` WebSocket broadcast; **no** plugin code execution |
|
||||
| P2P data channel | `plugin-message-bus` events (ignored by chat reducers) |
|
||||
|
||||
Server plugin **data** HTTP routes return **410 Gone** (`PLUGIN_DATA_DISABLED`).
|
||||
|
||||
## Server REST (`/api/servers/:serverId/plugins`)
|
||||
|
||||
| Method | Path | Auth |
|
||||
|--------|------|------|
|
||||
| GET | `/` | Public (metadata snapshot) |
|
||||
| PUT | `/:pluginId/requirement` | Bearer |
|
||||
| DELETE | `/:pluginId/requirement` | Bearer |
|
||||
| PUT | `/:pluginId/events/:eventName` | Bearer |
|
||||
| DELETE | `/:pluginId/events/:eventName` | Bearer |
|
||||
| GET/PUT/DELETE | `/:pluginId/data/*` | 410 (disabled) |
|
||||
|
||||
## WebSocket
|
||||
|
||||
| type | Direction | Purpose |
|
||||
|------|-----------|---------|
|
||||
| `plugin_requirements` | Server → client | Snapshot after `join_server` / `view_server` |
|
||||
| `plugin_event` | Client → server → room | Validated broadcast of plugin events |
|
||||
| `plugin_error` | Server → client | Validation failure |
|
||||
|
||||
See [signaling.md](signaling.md).
|
||||
|
||||
## Manifest scopes
|
||||
|
||||
- `scope: "client"` — global desktop/browser plugins (Settings → Client plugins).
|
||||
- `scope: "server"` — per chat-server plugins; join may block until user consents to required plugins.
|
||||
|
||||
Store source manifests support HTTPS `bundle`/`bundleUrl` with optional SHA-256 `integrity` verification before `import()`.
|
||||
|
||||
## Electron IPC / storage
|
||||
|
||||
- `list-local-plugin-manifests`, `get-local-plugins-path`, `grant-plugin-read-root`
|
||||
- Plugin preferences and `api.clientData` / `api.serverData` → `plugin_data` table (user-scoped)
|
||||
- Cached bundles: `plugin-bundles/<plugin-id>/<version>/main.js`
|
||||
|
||||
## Client API surface (summary)
|
||||
|
||||
Plugins receive `TojuClientPluginApi`: `commands`, `ui.mountElement`, `ui.registerToolbarAction`, `messageBus`, `messages.setTyping`, `context.getCurrent()`, `clientData`/`serverData` async storage.
|
||||
|
||||
## Related
|
||||
|
||||
- [signaling.md](signaling.md) — `plugin_event`, `plugin_requirements`
|
||||
- [server-directory.md](server-directory.md) — server-scoped install on join
|
||||
- [authentication.md](authentication.md) — bearer on metadata mutations
|
||||
- Domain README: [`toju-app/src/app/domains/plugins/README.md`](../../toju-app/src/app/domains/plugins/README.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Initial cross-context plugin contract |
|
||||
@@ -0,0 +1,53 @@
|
||||
# Push Notifications
|
||||
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
Mobile remote push (FCM/APNs) and server-side device token storage. Desktop uses local/Electron notifications via the `notifications` domain.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
| Layer | Owns |
|
||||
|-------|------|
|
||||
| Signaling server | `device_tokens` SQLite table; FCM/APNs dispatch |
|
||||
| Capacitor client | Token registration via `MobilePushRegistrationService` |
|
||||
| Server env | FCM service account + APNs key configuration |
|
||||
|
||||
## REST API (`/api/users/device-tokens`)
|
||||
|
||||
All routes require bearer; `userId` must match authenticated identity.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| POST | `/` | Upsert `{ userId, platform: "android"\|"ios", token }` |
|
||||
| GET | `/:userId` | List tokens for user |
|
||||
| POST | `/:userId/dispatch` | Manual push `{ title, body, data? }` (ops/testing) |
|
||||
|
||||
## Server configuration
|
||||
|
||||
Repository-root `.env`:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `FCM_SERVICE_ACCOUNT_PATH` or `FCM_SERVICE_ACCOUNT_JSON` | Android FCM HTTP v1 |
|
||||
| `APNS_KEY_PATH`, `APNS_KEY_ID`, `APNS_TEAM_ID` | iOS APNs HTTP/2 |
|
||||
| `APNS_BUNDLE_ID` | Default `com.metoyou.app` |
|
||||
| `APNS_USE_SANDBOX` | Development builds |
|
||||
|
||||
## Mobile client
|
||||
|
||||
- Optional Firebase: app starts without `google-services.json`; registration skipped when remote push not configured.
|
||||
- See [mobile-capacitor.md](mobile-capacitor.md) for FCM/APNs setup, permissions, and in-call local notifications.
|
||||
|
||||
## Related
|
||||
|
||||
- [authentication.md](authentication.md) — bearer + userId match rules
|
||||
- [mobile-capacitor.md](mobile-capacitor.md) — client registration and foreground service
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Initial push notification contract |
|
||||
@@ -0,0 +1,96 @@
|
||||
# Server Directory
|
||||
|
||||
> **Area:** server-directory
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
Server directory is the cross-context contract for **which signaling servers exist**, how clients health-check and route to them, and how public/private chat-servers are created, joined, updated, moderated, and discovered over REST. It spans the signaling **server** (`server/src/routes/servers.ts`, CQRS handlers) and the product **client** (`server-directory` domain + `ServerDirectoryFacade`).
|
||||
|
||||
Curated browse lists (featured/trending) are documented separately in [server-discovery.md](server-discovery.md). WebSocket membership (`join_server`, presence) is in [signaling.md](signaling.md).
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Server: persist public server records, memberships, channels, roles, bans, invites, join requests; expose REST CRUD and access checks.
|
||||
- Client: maintain configured endpoint list, health/compatibility probes, canonical endpoint dedup by `serverInstanceId`, room `sourceId`/`sourceUrl` affinity, and HTTP orchestration for all server operations.
|
||||
- It does NOT own: P2P chat transport, voice WebRTC, or local Electron room/message persistence (except mirroring server metadata into local DB after join).
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **ServerEndpoint:** configured signaling base URL with health status, latency, and version compatibility.
|
||||
- **ServerInfo:** public server card shape returned by search/discovery/GET — includes `sourceId`, `sourceName`, `sourceUrl` filled by the client API layer.
|
||||
- **Room signal affinity:** each saved room records which endpoint registered it; reconnect prefers that URL before fallback endpoints.
|
||||
- **serverInstanceId:** stable id from `GET /api/health` used to collapse alias URLs to one canonical endpoint.
|
||||
|
||||
## Public REST (no bearer)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/health` | Liveness, `serverVersion`, `serverInstanceId`, optional `serverTag` |
|
||||
| GET | `/api/servers` | Free-text search / public listing (`q`, `limit`) |
|
||||
| GET | `/api/servers/featured` | Curated popular list — [server-discovery.md](server-discovery.md) |
|
||||
| GET | `/api/servers/trending` | Curated active list — [server-discovery.md](server-discovery.md) |
|
||||
| GET | `/api/servers/:id` | Single server metadata |
|
||||
|
||||
## Protected REST (bearer required)
|
||||
|
||||
All mutations derive the actor from the session token; body user ids are not trusted.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| POST | `/api/servers` | Register a new public server |
|
||||
| PUT | `/api/servers/:id` | Update name, description, channels, icon metadata, access settings |
|
||||
| DELETE | `/api/servers/:id` | Unregister server (owner) |
|
||||
| POST | `/api/servers/:id/join` | Join or request access (password, invite, public) |
|
||||
| POST | `/api/servers/:id/leave` | Leave membership |
|
||||
| POST | `/api/servers/:id/heartbeat` | Refresh `lastSeen` for trending ranking |
|
||||
| POST | `/api/servers/:id/invites` | Create invite link — [invites-join-requests.md](invites-join-requests.md) |
|
||||
| GET | `/api/servers/:id/requests` | List pending join requests (moderators) |
|
||||
| POST | `/api/servers/:id/moderation/kick` | Remove member |
|
||||
| POST | `/api/servers/:id/moderation/ban` | Ban member (optional expiry) |
|
||||
| POST | `/api/servers/:id/moderation/unban` | Lift ban |
|
||||
|
||||
Join-request approval: `PUT /api/requests/:id` — [invites-join-requests.md](invites-join-requests.md).
|
||||
|
||||
Plugin metadata under `/api/servers/:serverId/plugins` — [plugins.md](plugins.md).
|
||||
|
||||
## Client endpoint lifecycle
|
||||
|
||||
1. Load endpoints from `localStorage` (`metoyou_server_endpoints`); reconcile with environment defaults.
|
||||
2. `testAllServers()` probes `GET /api/health` (5 s timeout); on failure falls back to `GET /api/servers`.
|
||||
3. Mark incompatible when `serverVersion` fails semantic compatibility check.
|
||||
4. `resolveCanonicalEndpoint()` collapses aliases sharing the same `serverInstanceId`.
|
||||
5. Cold-start room reconnect waits for the initial health sweep before opening WebSockets.
|
||||
|
||||
## Multi-endpoint behavior
|
||||
|
||||
| Operation | Fan-out |
|
||||
|-----------|---------|
|
||||
| Search (`searchServers` with `searchAllServers`) | All online endpoints, dedupe by server id |
|
||||
| Discovery (featured/trending) | All online endpoints + 404→public list fallback |
|
||||
| Room CRUD/join | Authoritative room `sourceUrl` first; temporary fallback to other compatible endpoints on outage |
|
||||
|
||||
Only `status === 'incompatible'` stops fallback with an update-required message. Network errors and Cloudflare 521/522 must continue to the next endpoint.
|
||||
|
||||
## Server-owned channel metadata
|
||||
|
||||
`PUT /api/servers/:id` persists the server's `channels` array (text + voice). The client round-trips channel create/rename/delete through this API — local-only channel state is not authoritative. Server-side normalisation deduplicates names within each channel type.
|
||||
|
||||
## WebSocket complement
|
||||
|
||||
After REST join, the client sends `join_server` on the room's signaling URL. Presence (`server_users`, `user_joined`, `user_left`) is room-scoped on the WebSocket — see [signaling.md](signaling.md).
|
||||
|
||||
## Related
|
||||
|
||||
- [server-discovery.md](server-discovery.md) — featured/trending ranking and browse UI
|
||||
- [authentication.md](authentication.md) — bearer tokens for mutations
|
||||
- [invites-join-requests.md](invites-join-requests.md) — invite links and approval workflow
|
||||
- [signal-server-tag.md](signal-server-tag.md) — `serverTag` on health + profile cards
|
||||
- Product-client domain README: [`toju-app/src/app/domains/server-directory/README.md`](../../toju-app/src/app/domains/server-directory/README.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Initial cross-context server-directory REST contract |
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Area:** server-directory
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2025-02-14
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -67,13 +67,24 @@ Both endpoints live in `server/src/routes/servers.ts` and **must be registered b
|
||||
## Client internals
|
||||
|
||||
- `ServerDirectoryApiService.getFeaturedServers()` / `getTrendingServers()` call the routes through a shared private `getDiscoveryServers(path)` helper and normalise into `ServerInfo[]`.
|
||||
- **Multi-endpoint fan-out:** discovery queries **every online endpoint** (`getSearchableEndpoints()` + `forkJoin`), deduplicated by server ID — mirroring free-text search. Querying only the active endpoint made the default `/servers` view appear empty when populated servers lived on other endpoints.
|
||||
- **Legacy 404 fallback:** when `GET /api/servers/featured` or `/trending` returns **404** (older signal servers resolve those paths as `/servers/:id`), `fetchDiscoveryFromEndpoint` falls back per-endpoint to the public `GET /api/servers` listing (`fetchPublicServerListForDiscovery`) instead of returning `[]`. Verified in `server-directory-api.service.spec.ts` (including production hosts like `signal.toju.app`).
|
||||
- `ServerDirectoryService` → `ServerDirectoryFacade` expose `getFeaturedServers()` / `getTrendingServers()` as the domain boundary.
|
||||
- `FindServersComponent` (`/servers`) composes **Recently active** (the user's saved rooms, capped at 6), **Featured**, and **Trending** sections, all rendered through `app-server-browser` with `[showMyServers]="true"`.
|
||||
- `DashboardComponent` (`/dashboard`) is a single-column landing page (max-width centered, no in-page sidebars): a header greeting (no emoji), a global search with `Ctrl+K` focus and localStorage-backed **Recent Searches** chips shown beneath it, three primary action cards (Find People → `/people`, Find Servers → `/servers`, Create Server → `/create-server` — one link each), and discovery panels **People you might know**, **Popular Servers**, **Your Friends**, and **Recently Active Servers**. Each list is capped at 5 (`DISCOVERY_LIMIT`). It loads `popularServers` on init from `getFeaturedServers(5)`, falling back to `getTrendingServers(5)` when featured is empty; reuses `app-friend-button` for Add and `app-user-avatar` for people rows. `peopleYouMightKnow` excludes existing friends (via `FriendService.friendIds()`); `friends` lists discovered people who are friends. "See all" header links route to the matching `/people` or `/servers` page (no duplicated footer links). Recent searches are recorded on Enter (deduped, most-recent-first, capped at 8) and persisted under `metoyou_dashboard_recent_searches`.
|
||||
- The servers-rail top button (`servers-rail.component`) is the **Dashboard** button (`lucideLayoutDashboard`, `title="Dashboard"`); its `goToDashboard()` handler deselects any active voice server and navigates to `/dashboard`. A **Create a server** button (`lucidePlus`, `data-testid="server-rail-create"`) sits below the saved-server icons and opens `app-create-server-dialog` (a Toju modal on desktop / bottom sheet on mobile) which dispatches `RoomsActions.createRoom` directly; the dashboard / `/create-server` route remains as an alternative entry point. Rail icons (`h-12 w-12`, `md:h-11 w-11`) animate their corner radius on hover and `:active` for a Discord-style squircle effect.
|
||||
- On mobile (`ViewportService.isMobile()`), `DashboardComponent`, `FindPeopleComponent` (`/people`), and `FindServersComponent` (`/servers`) each mount their page body inside a single `<swiper-container>` slide next to `app-servers-rail` (rail `shrink-0`, content `flex-1` with a left border), mirroring the chat-room / DM-workspace mobile layout so the primary navigation rail stays reachable. The page body is shared between the desktop and mobile branches via an `<ng-template #pageContent>` + `[ngTemplateOutlet]`, and each component declares `schemas: [CUSTOM_ELEMENTS_SCHEMA]` for the Swiper custom elements.
|
||||
- On mobile (`ViewportService.isMobile()`), discovery routes (`/dashboard`, `/people`, `/servers`) render their page body full-width via `<ng-template #pageContent>` + `[ngTemplateOutlet]`. The **servers rail is global** in `app.html` (`shouldShowMobileAppServersRail` in `core/platform/mobile-shell-layout.rules.ts`) — discovery pages must **not** embed a second `<app-servers-rail>` or Swiper stack. Chat-room and DM-workspace routes keep their own embedded rail inside Swiper and hide the global shell rail (see `toju-app/AGENTS.md`).
|
||||
|
||||
## Related
|
||||
|
||||
- Product-client domain README: `toju-app/src/app/domains/server-directory/README.md`
|
||||
- Full server-directory REST contract (CRUD, join, moderation): [server-directory.md](server-directory.md)
|
||||
- People discovery (`/people`): `toju-app/src/app/domains/direct-message/README.md`
|
||||
- Mobile shell: [mobile-capacitor.md](mobile-capacitor.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
| ---------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-07-05 | Added multi-endpoint fan-out and 404 fallback; corrected mobile shell layout (global rail, no per-page Swiper) |
|
||||
| 2025-02-14 | Initial documentation |
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
# Signal Server Tag
|
||||
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
Users registered on a signal server can show that server's display tag on their profile card (opened by clicking their name or avatar).
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Server: expose a human-readable tag per **endpoint** (not per user identity).
|
||||
- Client: resolve tag for a user's **home** signaling server (`homeSignalServerUrl`) and render on profile cards.
|
||||
|
||||
## Server configuration
|
||||
|
||||
`server/data/variables.json` accepts an optional `serverTag` string. When omitted, the server falls back to its public URL built from `serverProtocol`, `serverHost`, and `serverPort`.
|
||||
`server/data/variables.json` accepts an optional `serverTag` string. When omitted, the server falls back to its public URL built from `serverProtocol`, `serverHost`, and `serverPort` (`server/src/config/variables.ts`).
|
||||
|
||||
## Health API
|
||||
|
||||
@@ -12,11 +20,27 @@ Users registered on a signal server can show that server's display tag on their
|
||||
|
||||
## WebSocket presence
|
||||
|
||||
The client sends `homeSignalServerUrl` in `identify` messages. The signaling server echoes that value in `server_users` and `user_joined` payloads so other clients can resolve the correct tag.
|
||||
The client sends `homeSignalServerUrl` in `identify` messages. The signaling server echoes that value in `server_users` and `user_joined` payloads so other clients can resolve the correct tag. See [signaling.md](signaling.md).
|
||||
|
||||
## Client behavior
|
||||
|
||||
- Login and registration store `homeSignalServerUrl` on the current user.
|
||||
- Profile cards show the resolved tag beside the username in muted text.
|
||||
- Profile cards show the resolved tag beside the username in muted text (`profile-signal-server-tag.component`).
|
||||
- Configured labels render as `#tag`; URL fallbacks render as a globe icon with the URL in a tooltip.
|
||||
- Tag resolution prefers the endpoint's cached `serverTag` from health checks, then falls back to the stored home URL.
|
||||
- Tag resolution (`signal-server-tag.rules.ts`): match `homeSignalServerUrl` against configured endpoints and prefer cached health `serverTag`; otherwise show the raw URL fallback.
|
||||
|
||||
## Testing
|
||||
|
||||
- `toju-app/src/app/domains/server-directory/domain/logic/signal-server-tag.rules.spec.ts`
|
||||
- `server/src/websocket/handler-status.spec.ts` (presence payload includes `homeSignalServerUrl`)
|
||||
|
||||
## Related
|
||||
|
||||
- [server-directory.md](server-directory.md) — endpoint health cache
|
||||
- [authentication.md](authentication.md) — `homeSignalServerUrl` on identify
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Clarified per-endpoint tag vs per-user home URL; added test references |
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Signaling (WebSocket)
|
||||
|
||||
> **Area:** realtime
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
The signaling server exposes a single WebSocket per origin that carries identity, room membership, presence, WebRTC SDP/ICE relay, selected server-relayed chat/DM/voice fallbacks, plugin events, and multi-device `account_sync`. The product client implements the consumer in `toju-app/src/app/infrastructure/realtime/signaling/`.
|
||||
|
||||
**Canonical contract:** this document and [`server/src/websocket/handler.ts`](../../server/src/websocket/handler.ts). Do **not** treat `toju-app/src/app/shared-kernel/signaling-contracts.ts` as authoritative — it lists legacy types (`join`, `leave`, `chat`, `ice-candidate`) that do not match the live server.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Authenticate connections via `identify` (session token).
|
||||
- Track per-connection room membership and broadcast room-scoped presence.
|
||||
- Relay WebRTC offers/answers/ICE between peers that share server membership (or DM/direct-call rules).
|
||||
- Relay narrow server fallbacks when P2P data channels are unavailable (chat, DM, voice presence).
|
||||
- Forward `account_sync` payloads to sibling connections for the same user identity.
|
||||
- It does NOT own: P2P data-channel payloads (attachments, message inventory, custom emoji chunks, plugin message bus), local persistence, or REST server-directory APIs.
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **Envelope:** JSON object with required `type` string; additional fields vary by type.
|
||||
- **oderId:** user identity on the wire (legacy spelling, matches server code).
|
||||
- **clientInstanceId:** per-tab/device id stored in `sessionStorage`; multiple open connections per `oderId` are allowed.
|
||||
- **connectionScope:** optional string grouping connections (e.g. browser profile).
|
||||
- **voiceActive:** server marks the connection that owns outbound RTC relay for a user; updated from `voice_state` payloads.
|
||||
|
||||
## Ordering invariants
|
||||
|
||||
1. **`identify` before anything else** — unauthenticated connections receive `auth_required` for all types except `identify` and `keepalive`.
|
||||
2. **Per-connection serialization** — `handleWebSocketMessage` chains handlers per `connectionId` so `join_server` cannot run while `identify` is still awaiting the token DB lookup.
|
||||
3. **Client replay on reconnect** — `SignalingManager.reIdentifyAndRejoin` sends `identify` then re-joins rooms; see [`toju-app/src/app/infrastructure/realtime/README.md`](../../toju-app/src/app/infrastructure/realtime/README.md).
|
||||
|
||||
## Connection lifecycle (server → client)
|
||||
|
||||
On connect the server assigns a `connectionId` and may emit:
|
||||
|
||||
| type | When |
|
||||
|------|------|
|
||||
| `connected` | Immediately after WebSocket open (`server/src/websocket/index.ts`) |
|
||||
|
||||
On disconnect, if the connection was voice-active, the server broadcasts a cleared `voice_state` via `finalizeVoiceDisconnectForConnection`.
|
||||
|
||||
## Inbound types (client → server)
|
||||
|
||||
| type | Auth | Behavior |
|
||||
|------|------|----------|
|
||||
| `keepalive` | Optional | Responds `keepalive_ack` with `serverTime` |
|
||||
| `identify` | N/A (establishes auth) | Validates session token; sets `oderId`, profile fields; evicts stale same `(oderId, connectionScope, clientInstanceId)` sockets; emits `account_sync_peer_online` to siblings |
|
||||
| `join_server` | Required | Access check; adds `serverId` to connection; sends `server_users` + `plugin_requirements`; may broadcast `user_joined` (identity-aware) |
|
||||
| `view_server` | Required | Switches `viewedServerId`; refreshes `server_users` + `plugin_requirements` |
|
||||
| `leave_server` | Required | Removes membership; may broadcast `user_left` with remaining `serverIds` |
|
||||
| `offer`, `answer`, `ice_candidate` | Required | Relay to `targetUserId` when peers share server membership |
|
||||
| `direct-message`, `direct-message-status`, `direct-message-mutation`, `direct-message-typing`, `direct-message-sync-request`, `direct-message-sync`, `direct-call` | Required | Relay to `targetUserId` (DM rules — no shared-server requirement) |
|
||||
| `server_icon_peer_request`, `server_icon_peer_data` | Required | Relay when both users share `serverId` membership |
|
||||
| `chat_message` | Required | Broadcast to server members (excludes sender connection) |
|
||||
| `voice_state` | Required | Updates `voiceActive` / snapshot; broadcast to server members |
|
||||
| `voice_client_takeover` | Required | Notifies sibling connections via `notifyOtherConnectionsForOderId` |
|
||||
| `account_sync` | Required | Forwards `payload` object to other connections for same `oderId` |
|
||||
| `typing` | Required | Broadcast `user_typing` to server members |
|
||||
| `status_update` | Required | Broadcast `status_update` (`online` \| `away` \| `busy` \| `offline`) to joined servers |
|
||||
| `server_icon_available` | Required | Records local `iconUpdatedAt` per server on the connection |
|
||||
| `server_icon_sync_request` | Required | Responds `server_icon_sync_peers` with peers having newer icons |
|
||||
| `plugin_event` | Required | Validates against server plugin metadata; broadcast or `plugin_error` |
|
||||
|
||||
Unknown inbound types are logged and ignored.
|
||||
|
||||
### `identify` request fields
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `token` | Yes | Session token from REST login/register |
|
||||
| `oderId` | No | If present, must match token user id |
|
||||
| `displayName` | No | Defaults to existing or `"User"` |
|
||||
| `description`, `profileUpdatedAt`, `homeSignalServerUrl` | No | Profile card fields |
|
||||
| `clientInstanceId` | No | Per-tab id for multi-device and voice ownership |
|
||||
| `connectionScope` | No | Eviction scope for stale sockets |
|
||||
|
||||
Errors: `auth_error` with `MISSING_TOKEN`, `INVALID_TOKEN`, or `USER_ID_MISMATCH`.
|
||||
|
||||
## Server-emitted types (server → client)
|
||||
|
||||
| type | Trigger |
|
||||
|------|---------|
|
||||
| `keepalive_ack` | Response to `keepalive` |
|
||||
| `auth_required` | Message before `identify` |
|
||||
| `auth_error` | Failed `identify` |
|
||||
| `access_denied` | `join_server` rejected (`serverId`, `reason`) |
|
||||
| `server_users` | After join/view; lists unique users in room |
|
||||
| `user_joined` | New identity in server (excludes same identity's connections) |
|
||||
| `user_left` | Identity fully left server (`serverIds` = remaining rooms) |
|
||||
| `plugin_requirements` | Plugin install snapshot after join/view |
|
||||
| `plugin_error` | Invalid plugin event |
|
||||
| `server_icon_sync_peers` | Response to `server_icon_sync_request` |
|
||||
| `account_sync_peer_online` | Sibling connection came online |
|
||||
| `account_sync` | Relayed multi-device payload |
|
||||
| `chat_message` | Relayed room chat fallback |
|
||||
| `user_typing` | Typing indicator |
|
||||
| `status_update` | Presence status change |
|
||||
| `voice_state` | Voice roster / disconnect cleanup |
|
||||
| `voice_client_takeover` | Another tab took voice ownership |
|
||||
| `plugin_event` | Broadcast plugin event |
|
||||
| Forwarded RTC/DM | Copies of client messages with `fromUserId` set |
|
||||
|
||||
## Relay rules
|
||||
|
||||
- **RTC (`offer` / `answer` / `ice_candidate`):** forwarded when sender and target share any server membership.
|
||||
- **Direct signaling types:** forwarded to `targetUserId` without shared-server check.
|
||||
- **Server icon P2P:** both users must be members of `message.serverId`.
|
||||
- **Broadcasts** (`chat_message`, `voice_state`, `typing`, etc.): exclude sender **connection id** (not whole identity) so multi-device sessions still receive updates.
|
||||
- **`user_joined` / `user_left`:** exclude whole **identity** so other users do not see duplicate join/leave for multiple tabs.
|
||||
|
||||
## P2P vs signaling split
|
||||
|
||||
| Transport | Carries |
|
||||
|-----------|---------|
|
||||
| **WebRTC data channel** | Chat events, attachments, custom emoji, message revisions/inventory, profile avatar bytes, voice/screen control, plugin message bus, game activity |
|
||||
| **WebSocket signaling** | Identity, membership, presence, RTC SDP/ICE, chat/DM/voice fallbacks, `account_sync`, plugin events |
|
||||
|
||||
Server-relayed chat (`chat_message`) and DM types exist so users see written chat and delivery state while data channels are down. Media, attachments, and inventory sync remain peer-plane responsibilities.
|
||||
|
||||
## Multi-device (`account_sync`)
|
||||
|
||||
The client wraps relayable local changes in `account_sync` envelopes. The server forwards the inner `payload` to other open connections for the same `oderId`. When a device identifies, siblings receive `account_sync_peer_online` and push snapshots (saved servers, friends, emoji library, chat history batches, etc.). See [authentication.md](authentication.md) and domain-specific feature docs.
|
||||
|
||||
## Client implementation map
|
||||
|
||||
| Concern | Location |
|
||||
|---------|----------|
|
||||
| One socket per signal URL | `signaling/signaling.manager.ts` |
|
||||
| Route picker | `signaling/signaling-transport-handler.ts` |
|
||||
| Room affinity | `signaling/server-signaling-coordinator.ts` |
|
||||
| Inbound dispatch | `signaling/signaling-message-handler.ts` |
|
||||
| Constants (intervals, types) | `realtime.constants.ts` |
|
||||
|
||||
## Related
|
||||
|
||||
- [authentication.md](authentication.md) — session tokens and `identify` trust boundary
|
||||
- [direct-messaging.md](direct-messaging.md) — DM envelope relay types
|
||||
- [voice-webrtc.md](voice-webrtc.md) — RTC relay and `voice_state`
|
||||
- [plugins.md](plugins.md) — `plugin_event` / `plugin_requirements`
|
||||
- [message-integrity.md](message-integrity.md) — signed revisions (P2P; `account_sync` relay)
|
||||
- Product client deep dive: [`toju-app/src/app/infrastructure/realtime/README.md`](../../toju-app/src/app/infrastructure/realtime/README.md)
|
||||
- Server handler: [`server/src/websocket/handler.ts`](../../server/src/websocket/handler.ts)
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-05 | Initial canonical envelope catalog; deprecates `shared-kernel/signaling-contracts.ts` as wire source |
|
||||
@@ -0,0 +1,68 @@
|
||||
# Voice & WebRTC
|
||||
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-07-05
|
||||
|
||||
## Overview
|
||||
|
||||
Voice channels, camera, and screen-share use direct **WebRTC** peer connections between clients. The signaling server relays SDP offers/answers/ICE and **voice presence** (`voice_state`); media never flows through the server.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
| Layer | Owns |
|
||||
|-------|------|
|
||||
| `infrastructure/realtime/` | WebRTC sessions, negotiation, data channels, RNNoise worklet |
|
||||
| `voice-connection` / `voice-session` domains | Facades, workspace UI, settings, multi-device ownership |
|
||||
| `screen-share` domain | Source picker, quality presets (Electron) |
|
||||
| Signaling server | RTC relay + `voice_state` / `voice_client_takeover` broadcast |
|
||||
|
||||
## WebSocket signaling types
|
||||
|
||||
| type | Purpose |
|
||||
|------|---------|
|
||||
| `offer`, `answer`, `ice_candidate` | WebRTC negotiation relay to `targetUserId` |
|
||||
| `voice_state` | Voice roster (mute/deafen/speaking, channel id); sets `voiceActive` on one connection per user |
|
||||
| `voice_client_takeover` | Notify sibling tabs to yield voice ownership |
|
||||
|
||||
Relay rules: RTC messages require shared server membership (except DM-specific types). See [signaling.md](signaling.md).
|
||||
|
||||
## Multi-device voice
|
||||
|
||||
- Only one connection per `oderId` may be `voiceActive`; RTC offers route to that connection (fallback: any open connection).
|
||||
- Other tabs show passive UI and may send `voice_client_takeover`.
|
||||
- `clientInstanceId` in `identify` and voice payloads distinguishes tabs.
|
||||
|
||||
## Media pipeline (client)
|
||||
|
||||
- **Voice:** `getUserMedia` → optional RNNoise AudioWorklet → gain → same-room peer routing only.
|
||||
- **Camera:** separate video track; same-room filter.
|
||||
- **Screen share:** on-demand via data-channel `SCREEN_SHARE_REQUEST`; platform-specific capture (browser `getDisplayMedia`, Electron picker, Linux PulseAudio routing).
|
||||
|
||||
## P2P data channel
|
||||
|
||||
Carries voice/screen control messages, chat, attachments, and state sync — not server-relayed. Data-channel failure triggers peer renegotiation or full rebuild (see realtime README). When a failed control channel is replaced (`replaceDataChannel`), the old channel is closed first so its SCTP resources are released.
|
||||
|
||||
## Media memory invariants (2026-07-14, RAM investigation)
|
||||
|
||||
- `removePeer` / `closeAllPeers` clear **all four** remote stream maps, including `remotePeerCameraStreams` (previously leaked per departed peer).
|
||||
- Video tiles (`voice-workspace-stream-tile`) pause and null `srcObject` in `ngOnDestroy` (`voice-workspace-stream-video.rules.ts`) so Chromium releases decoder/frame buffers immediately.
|
||||
- `debug-network-metrics` drops a peer's entry when the peer is fully removed and caps the store at `MAX_TRACKED_DEBUG_NETWORK_PEERS` = 200 (oldest evicted).
|
||||
- Electron registers `setDisplayMediaRequestHandler` once per app run (guarded in `create-window.ts`), not on every window recreation.
|
||||
|
||||
## Mobile / Capacitor
|
||||
|
||||
Background voice uses Android foreground service + iOS audio/CallKit bridges — [mobile-capacitor.md](mobile-capacitor.md). Screen share is limited on mobile WebViews.
|
||||
|
||||
## Related
|
||||
|
||||
- [signaling.md](signaling.md) — envelope catalog
|
||||
- [direct-messaging.md](direct-messaging.md) — private calls share `PeerDeliveryService`
|
||||
- [`toju-app/src/app/infrastructure/realtime/README.md`](../../toju-app/src/app/infrastructure/realtime/README.md) — negotiation, recovery, RNNoise
|
||||
- Domain READMEs: `voice-connection`, `voice-session`, `screen-share`
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-14 | Media memory invariants: camera-stream map cleanup, tile `srcObject` release, debug-metrics cap, single display-media handler registration, replaced data channels closed |
|
||||
| 2026-07-05 | Initial cross-context voice/WebRTC contract |
|
||||
@@ -21,13 +21,14 @@ if [ "$SSL" = "true" ]; then
|
||||
fi
|
||||
|
||||
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key"
|
||||
WAIT_URL="https://localhost:4200"
|
||||
HEALTH_URL="https://localhost:3001/api/health"
|
||||
# Use 127.0.0.1 so wait-on does not hit a stale HTTP listener on localhost (::1).
|
||||
WAIT_URL="https://127.0.0.1:4200"
|
||||
HEALTH_URL="https://127.0.0.1:3001/api/health"
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
else
|
||||
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0"
|
||||
WAIT_URL="http://localhost:4200"
|
||||
HEALTH_URL="http://localhost:3001/api/health"
|
||||
WAIT_URL="http://127.0.0.1:4200"
|
||||
HEALTH_URL="http://127.0.0.1:3001/api/health"
|
||||
fi
|
||||
|
||||
exec npx concurrently --kill-others \
|
||||
|
||||
@@ -10,14 +10,20 @@ This page maps the app routes and important DOM areas. It is useful for plugin a
|
||||
|
||||
| Route | Component | Purpose |
|
||||
| ---------------------------- | ------------------------- | --------------------------------------------------------------------- |
|
||||
| `/` | Redirect | Redirects to `/search`. |
|
||||
| `/` | Redirect | Redirects to `/dashboard`. |
|
||||
| `/login` | `LoginComponent` | User login. |
|
||||
| `/register` | `RegisterComponent` | User registration. |
|
||||
| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. |
|
||||
| `/search` | `ServerSearchComponent` | Search and join servers. |
|
||||
| `/dashboard` | `DashboardComponent` | Landing dashboard after sign-in. |
|
||||
| `/people` | `FindPeopleComponent` | Discover and start direct messages with people. |
|
||||
| `/servers` | `FindServersComponent` | Search, discover, and join servers. |
|
||||
| `/create-server` | `CreateServerComponent` | Create a new server. |
|
||||
| `/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. |
|
||||
| `/pm` | `DmWorkspaceComponent` | Private-message workspace (alias of the DM workspace). |
|
||||
| `/pm/:conversationId` | `DmWorkspaceComponent` | A selected private-message conversation. |
|
||||
| `/call/:callId` | `PrivateCallComponent` | Active private (1:1) call. |
|
||||
| `/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()`. |
|
||||
|
||||
@@ -124,7 +124,7 @@ Important routes:
|
||||
|
||||
| Route | Purpose |
|
||||
| ------------------------------- | ------------------------------------------------------------------- |
|
||||
| `/search` | Search and join servers. |
|
||||
| `/servers` | Search, discover, and join servers. |
|
||||
| `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. |
|
||||
| `/dm` and `/dm/:conversationId` | Direct-message workspace. |
|
||||
| `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. |
|
||||
|
||||
@@ -48,7 +48,8 @@ export const test = base.extend<MultiClientFixture>({
|
||||
|
||||
const context = await browser.newContext({
|
||||
permissions: ['microphone', 'camera'],
|
||||
baseURL: 'http://localhost:4200'
|
||||
baseURL: 'http://localhost:4200',
|
||||
viewport: { width: 1440, height: 900 }
|
||||
});
|
||||
|
||||
await installTestServerEndpoint(context, testServer.port);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
export async function openTitleBarMenu(page: Page): Promise<void> {
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
|
||||
await expect(menuButton).toBeVisible({ timeout: 15_000 });
|
||||
await menuButton.click();
|
||||
await expect(page.locator('app-title-bar .absolute.right-0.top-full').first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function openPluginStore(page: Page): Promise<void> {
|
||||
await openTitleBarMenu(page);
|
||||
await page.getByRole('button', { name: 'Plugin Store' }).click();
|
||||
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
export async function openSettingsFromMenu(page: Page): Promise<void> {
|
||||
await openTitleBarMenu(page);
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type APIRequestContext, type Page } from '@playwright/test';
|
||||
|
||||
export const AUTH_TOKENS_STORAGE_KEY = 'metoyou.authTokens';
|
||||
export const SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY = 'metoyou.signalServerCredentials';
|
||||
|
||||
export interface AuthSession {
|
||||
id: string;
|
||||
@@ -56,6 +57,36 @@ export async function loginTestUser(
|
||||
return await response.json() as AuthSession;
|
||||
}
|
||||
|
||||
export async function readSignalServerCredentialFromPage(
|
||||
page: Page,
|
||||
serverUrl: string
|
||||
): Promise<{ userId: string; token: string; username: string } | null> {
|
||||
return await page.evaluate(({ storageKey, url }) => {
|
||||
try {
|
||||
const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, {
|
||||
userId: string;
|
||||
token: string;
|
||||
username: string;
|
||||
expiresAt: number;
|
||||
}>;
|
||||
const normalizedUrl = url.trim().replace(/\/+$/, '');
|
||||
const entry = store[normalizedUrl];
|
||||
|
||||
if (!entry || entry.expiresAt <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: entry.userId,
|
||||
token: entry.token,
|
||||
username: entry.username
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { storageKey: SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY, url: serverUrl });
|
||||
}
|
||||
|
||||
export async function readAuthTokenFromPage(page: Page, serverUrl: string): Promise<string | null> {
|
||||
return await page.evaluate(({ storageKey, url }) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
/** Dashboard omnibox (desktop placeholder copy changed with i18n refresh). */
|
||||
export function dashboardSearchInput(page: Page) {
|
||||
return page.getByRole('textbox', { name: 'Search people, servers, and invites' });
|
||||
}
|
||||
|
||||
export async function expectDashboardReady(page: Page, timeout = 30_000): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout });
|
||||
await expect(dashboardSearchInput(page)).toBeVisible({ timeout });
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { type Client } from '../fixtures/multi-client';
|
||||
import { LoginPage } from '../pages/login.page';
|
||||
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';
|
||||
|
||||
export const MULTI_DEVICE_PASSWORD = 'TestPass123!';
|
||||
export const MULTI_DEVICE_VOICE_CHANNEL = 'General';
|
||||
|
||||
export interface MultiDeviceCredentials {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface MultiDeviceScenario {
|
||||
clientA: Client;
|
||||
clientB: Client;
|
||||
credentials: MultiDeviceCredentials;
|
||||
serverName: string;
|
||||
messagesA: ChatMessagesPage;
|
||||
messagesB: ChatMessagesPage;
|
||||
roomA: ChatRoomPage;
|
||||
roomB: ChatRoomPage;
|
||||
}
|
||||
|
||||
export function uniqueMultiDeviceName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10_000)}`;
|
||||
}
|
||||
|
||||
export async function createMultiDeviceScenario(
|
||||
createClient: () => Promise<Client>,
|
||||
options: { suffix?: string; serverDescription?: string } = {}
|
||||
): Promise<MultiDeviceScenario> {
|
||||
const suffix = options.suffix ?? uniqueMultiDeviceName('multi-device');
|
||||
const credentials: MultiDeviceCredentials = {
|
||||
username: `multi_${suffix}`,
|
||||
displayName: 'Multi Device User',
|
||||
password: MULTI_DEVICE_PASSWORD
|
||||
};
|
||||
const serverName = `Multi Device Server ${suffix}`;
|
||||
const clientA = await createClient();
|
||||
const clientB = await createClient();
|
||||
|
||||
await warmClientPage(clientA.page);
|
||||
await warmClientPage(clientB.page);
|
||||
|
||||
const registerPage = new RegisterPage(clientA.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
|
||||
await expect(clientA.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const searchA = new ServerSearchPage(clientA.page);
|
||||
|
||||
await searchA.createServer(serverName, {
|
||||
description: options.serverDescription ?? 'Multi-device session coverage'
|
||||
});
|
||||
|
||||
await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
await waitForCurrentRoomName(clientA.page, serverName);
|
||||
|
||||
const roomA = new ChatRoomPage(clientA.page);
|
||||
|
||||
await roomA.ensureVoiceChannelExists(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
|
||||
await loginSecondDeviceIntoServer(clientB.page, credentials, serverName);
|
||||
await waitForCurrentRoomName(clientB.page, serverName);
|
||||
|
||||
const messagesA = new ChatMessagesPage(clientA.page);
|
||||
const messagesB = new ChatMessagesPage(clientB.page);
|
||||
const roomB = new ChatRoomPage(clientB.page);
|
||||
|
||||
await messagesA.waitForReady();
|
||||
await messagesB.waitForReady();
|
||||
|
||||
return {
|
||||
clientA,
|
||||
clientB,
|
||||
credentials,
|
||||
serverName,
|
||||
messagesA,
|
||||
messagesB,
|
||||
roomA,
|
||||
roomB
|
||||
};
|
||||
}
|
||||
|
||||
export async function loginSecondDeviceIntoServer(
|
||||
page: Page,
|
||||
credentials: MultiDeviceCredentials,
|
||||
serverName: string
|
||||
): Promise<void> {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.goto();
|
||||
await loginPage.login(credentials.username, credentials.password);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const search = new ServerSearchPage(page);
|
||||
|
||||
await search.joinServerFromSearch(serverName);
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
export async function expectCrossDeviceMessage(
|
||||
sender: ChatMessagesPage,
|
||||
receiver: ChatMessagesPage,
|
||||
message: string,
|
||||
timeout = 60_000
|
||||
): Promise<void> {
|
||||
await sender.sendMessage(message);
|
||||
|
||||
await expectSyncedMessage(receiver, message, timeout);
|
||||
}
|
||||
|
||||
/** Waits until a message sent elsewhere appears in the local chat history. */
|
||||
export async function expectSyncedMessage(
|
||||
receiver: ChatMessagesPage,
|
||||
message: string,
|
||||
timeout = 90_000
|
||||
): Promise<void> {
|
||||
await receiver.waitForReady();
|
||||
|
||||
await expect(receiver.getMessageItemByText(message)).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export async function expectSyncedMessageWithResync(
|
||||
page: Page,
|
||||
receiver: ChatMessagesPage,
|
||||
message: string,
|
||||
timeout = 60_000
|
||||
): Promise<void> {
|
||||
await receiver.waitForReady();
|
||||
|
||||
const alreadyVisible = await receiver.getMessageItemByText(message)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (!alreadyVisible) {
|
||||
await resyncChannelMessages(page);
|
||||
}
|
||||
|
||||
await expect(receiver.getMessageItemByText(message)).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export async function resyncChannelMessages(page: Page, channelName = 'general'): Promise<void> {
|
||||
const channel = page.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`).first();
|
||||
|
||||
await expect(channel).toBeVisible({ timeout: 10_000 });
|
||||
await channel.click({ button: 'right' });
|
||||
await page.getByRole('button', { name: 'Resync Messages' }).click();
|
||||
}
|
||||
|
||||
export async function closeClient(client: Client): Promise<void> {
|
||||
await client.context.close();
|
||||
}
|
||||
|
||||
export async function registerGuestAndJoinServer(
|
||||
page: Page,
|
||||
credentials: MultiDeviceCredentials,
|
||||
serverName: string
|
||||
): Promise<void> {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const search = new ServerSearchPage(page);
|
||||
|
||||
await search.joinServerFromSearch(serverName);
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
export async function reopenClientInServer(
|
||||
createClient: () => Promise<Client>,
|
||||
credentials: MultiDeviceCredentials,
|
||||
serverName: string
|
||||
): Promise<{ client: Client; messages: ChatMessagesPage }> {
|
||||
const client = await createClient();
|
||||
|
||||
await warmClientPage(client.page);
|
||||
await loginSecondDeviceIntoServer(client.page, credentials, serverName);
|
||||
|
||||
const messages = new ChatMessagesPage(client.page);
|
||||
|
||||
await messages.waitForReady();
|
||||
|
||||
return { client, messages };
|
||||
}
|
||||
|
||||
async function warmClientPage(page: Page): Promise<void> {
|
||||
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForLoadState('networkidle').catch(() => undefined);
|
||||
}
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function readClientInstanceId(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => {
|
||||
const sessionId = sessionStorage.getItem('metoyou.clientInstanceId')?.trim();
|
||||
|
||||
if (sessionId) {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
return localStorage.getItem('metoyou.clientInstanceId')?.trim() ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function logoutFromMenu(page: Page): Promise<void> {
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
export function channelsSidePanel(page: Page) {
|
||||
return page.locator('app-rooms-side-panel').first();
|
||||
}
|
||||
|
||||
export function membersSidePanel(page: Page) {
|
||||
return page.locator('app-rooms-side-panel').last();
|
||||
}
|
||||
|
||||
export function serverMemberRow(page: Page, displayName: string) {
|
||||
return membersSidePanel(page)
|
||||
.locator('[role="button"], button')
|
||||
.filter({ has: page.getByText(displayName, { exact: true }) })
|
||||
.first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gates cross-user assertions on real presence: the peer must show up in the
|
||||
* members panel before chat delivery between the two users can be expected.
|
||||
*/
|
||||
export async function expectServerPeerVisible(
|
||||
page: Page,
|
||||
displayName: string,
|
||||
timeout = 45_000
|
||||
): Promise<void> {
|
||||
await expect(serverMemberRow(page, displayName)).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export function passiveVoiceChannelJoinBadge(page: Page, channelName = MULTI_DEVICE_VOICE_CHANNEL) {
|
||||
return page
|
||||
.locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`)
|
||||
.getByText('Join', { exact: true });
|
||||
}
|
||||
|
||||
export async function expectPassiveVoiceOnDevice(
|
||||
page: Page,
|
||||
options: { timeout?: number; displayName?: string; channelName?: string } = {}
|
||||
): Promise<void> {
|
||||
const timeout = options.timeout ?? 45_000;
|
||||
const channelName = options.channelName ?? MULTI_DEVICE_VOICE_CHANNEL;
|
||||
const displayName = options.displayName;
|
||||
|
||||
await expect.poll(async () => {
|
||||
const membersLabel = await membersSidePanel(page)
|
||||
.getByText('In voice on another device', { exact: false })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const joinBadge = await passiveVoiceChannelJoinBadge(page, channelName).isVisible()
|
||||
.catch(() => false);
|
||||
const grayedVoiceUser = displayName
|
||||
? await channelsSidePanel(page).locator('.opacity-50')
|
||||
.filter({ hasText: displayName })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
: false;
|
||||
|
||||
return membersLabel || joinBadge || grayedVoiceUser;
|
||||
}, { timeout }).toBe(true);
|
||||
}
|
||||
|
||||
export async function expectActiveVoiceOnDevice(page: Page, timeout = 20_000): Promise<void> {
|
||||
await expect(page.locator('app-voice-controls, app-voice-workspace').first()).toBeVisible({ timeout });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
export const E2E_PLUGIN_SOURCE_URL = 'http://localhost:4200/plugins/e2e-plugin-source.json';
|
||||
export const E2E_PLUGIN_TITLE = 'E2E All API Plugin';
|
||||
|
||||
export async function addPluginSource(page: Page, sourceUrl = E2E_PLUGIN_SOURCE_URL): Promise<void> {
|
||||
const sourceInput = page.getByLabel('Plugin source manifest URL');
|
||||
|
||||
await expect(sourceInput).toBeVisible({ timeout: 15_000 });
|
||||
await sourceInput.click();
|
||||
await sourceInput.fill(sourceUrl);
|
||||
await expect(sourceInput).toHaveValue(sourceUrl, { timeout: 5_000 });
|
||||
|
||||
const addSourceButton = page.getByRole('button', { name: 'Add Source' });
|
||||
|
||||
await expect(addSourceButton).toBeEnabled({ timeout: 10_000 });
|
||||
await addSourceButton.click();
|
||||
await expect(page.getByRole('heading', { name: E2E_PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
export async function openSettingsModal(page: Page, settingsPage = 'general'): Promise<void> {
|
||||
await page.evaluate((targetPage) => {
|
||||
interface SettingsModalServiceHandle {
|
||||
open: (page: string) => void;
|
||||
}
|
||||
interface SettingsModalComponentHandle {
|
||||
mobilePage?: { set: (page: 'menu' | 'detail') => void };
|
||||
animating?: { set: (value: boolean) => void };
|
||||
navigate?: (page: string) => void;
|
||||
}
|
||||
interface AppComponentHandle {
|
||||
settingsModal?: SettingsModalServiceHandle;
|
||||
}
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => AppComponentHandle & SettingsModalComponentHandle;
|
||||
applyChanges?: (component: unknown) => void;
|
||||
}
|
||||
|
||||
const debugApi = (window as Window & { ng?: AngularDebugApi }).ng;
|
||||
const appRoot = document.querySelector('app-root');
|
||||
const settingsHost = document.querySelector('app-settings-modal');
|
||||
const appComponent = appRoot && debugApi?.getComponent(appRoot);
|
||||
const settingsComponent = settingsHost && debugApi?.getComponent(settingsHost);
|
||||
|
||||
if (!appComponent?.settingsModal?.open) {
|
||||
throw new Error('Angular debug API could not open settings modal');
|
||||
}
|
||||
|
||||
appComponent.settingsModal.open(targetPage);
|
||||
settingsComponent?.mobilePage?.set('menu');
|
||||
settingsComponent?.animating?.set(true);
|
||||
debugApi?.applyChanges?.(appComponent);
|
||||
debugApi?.applyChanges?.(settingsComponent);
|
||||
}, settingsPage);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId('settings-logout-button')).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function openSettingsDetailPage(page: Page, settingsPage: string): Promise<void> {
|
||||
await openSettingsModal(page, settingsPage);
|
||||
|
||||
await page.evaluate((targetPage) => {
|
||||
interface SettingsModalComponentHandle {
|
||||
navigate?: (page: string) => void;
|
||||
animating?: { set: (value: boolean) => 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?.navigate) {
|
||||
throw new Error('Angular debug API could not navigate settings modal');
|
||||
}
|
||||
|
||||
component.navigate(targetPage);
|
||||
component.animating?.set(true);
|
||||
debugApi?.applyChanges?.(component);
|
||||
}, settingsPage);
|
||||
}
|
||||
|
||||
export async function openSettingsDataPage(page: Page): Promise<void> {
|
||||
await openSettingsDetailPage(page, 'data');
|
||||
await expect(page.locator('app-data-settings')).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export { MOBILE_VIEWPORT };
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
/** Read how many signaling managers are currently connected for this page. */
|
||||
export async function getConnectedSignalManagerCount(page: Page): Promise<number> {
|
||||
return 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?: () => unknown[];
|
||||
};
|
||||
} | undefined;
|
||||
|
||||
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dual-signal setups create one RTCPeerConnection per remote peer per active
|
||||
* signaling manager, so the harness tracks `remotePeerCount * signalCount`
|
||||
* connected peer connections.
|
||||
*/
|
||||
export async function waitForConnectedRemotePeerMesh(
|
||||
page: Page,
|
||||
remotePeerCount: number,
|
||||
timeout = 45_000
|
||||
): Promise<void> {
|
||||
const signalCount = Math.max(await getConnectedSignalManagerCount(page), 1);
|
||||
const expectedCount = remotePeerCount * signalCount;
|
||||
const minimumCount = Math.max(remotePeerCount, expectedCount - signalCount);
|
||||
|
||||
await page.waitForFunction(
|
||||
(min) => ((window as unknown as {
|
||||
__rtcConnections?: RTCPeerConnection[];
|
||||
}).__rtcConnections ?? []).filter(
|
||||
(pc) => pc.connectionState === 'connected'
|
||||
).length >= min,
|
||||
minimumCount,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMinimumConnectedPeerMeshCount(
|
||||
page: Page,
|
||||
remotePeerCount: number
|
||||
): Promise<number> {
|
||||
const signalCount = Math.max(await getConnectedSignalManagerCount(page), 1);
|
||||
const expectedCount = remotePeerCount * signalCount;
|
||||
|
||||
return Math.max(remotePeerCount, expectedCount - signalCount);
|
||||
}
|
||||
|
||||
export async function waitForConnectedSignalManagerCount(
|
||||
page: Page,
|
||||
expectedCount: number,
|
||||
timeout = 30_000
|
||||
): Promise<void> {
|
||||
await expect.poll(async () => await getConnectedSignalManagerCount(page), {
|
||||
timeout,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(expectedCount);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
|
||||
/** Wait until the side-panel roster under a voice channel lists the expected user count. */
|
||||
export async function waitForVoiceRosterCount(
|
||||
page: Page,
|
||||
channelName: string,
|
||||
expectedCount: number,
|
||||
timeout = 45_000
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ expected, name }) => {
|
||||
const buttons = document.querySelectorAll(
|
||||
`app-rooms-side-panel button[data-channel-type="voice"][data-channel-name="${name}"]`
|
||||
);
|
||||
|
||||
for (const button of buttons) {
|
||||
const panel = button.closest('app-rooms-side-panel');
|
||||
|
||||
if (!panel || panel.getBoundingClientRect().width === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rosterDiv = button.nextElementSibling;
|
||||
|
||||
if (!rosterDiv) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const displayNames = new Set<string>();
|
||||
|
||||
rosterDiv.querySelectorAll('[appThemeNode="roomVoiceUserItem"] span.text-sm').forEach((element) => {
|
||||
const label = element.textContent?.trim();
|
||||
|
||||
if (label) {
|
||||
displayNames.add(label);
|
||||
}
|
||||
});
|
||||
|
||||
if (displayNames.size === expected) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
{ expected: expectedCount, name: channelName },
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { type Page } from '@playwright/test';
|
||||
import { type BrowserContext, type Page } from '@playwright/test';
|
||||
import type { WebRtcTestHarnessWindow } from './webrtc-test-window.types';
|
||||
|
||||
type RtcPeerConnectionArgs = ConstructorParameters<typeof RTCPeerConnection>;
|
||||
type AudioContextArgs = ConstructorParameters<typeof AudioContext>;
|
||||
|
||||
interface ScreenShareMediaStream extends MediaStream {
|
||||
__isScreenShare?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
||||
* Tracks all created peer connections and their remote tracks so tests
|
||||
* can inspect WebRTC state via `page.evaluate()`.
|
||||
*
|
||||
* Call immediately after page creation, before any `goto()`.
|
||||
* Call on the browser context (preferred) or page before any `goto()`.
|
||||
*/
|
||||
export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
export async function installWebRTCTracking(target: BrowserContext | Page): Promise<void> {
|
||||
const addInitScript = 'addInitScript' in target && typeof target.addInitScript === 'function'
|
||||
? target.addInitScript.bind(target)
|
||||
: (target as Page).addInitScript.bind(target);
|
||||
|
||||
await addInitScript(() => {
|
||||
const connections: RTCPeerConnection[] = [];
|
||||
const dataChannels: RTCDataChannel[] = [];
|
||||
const syntheticMediaResources: {
|
||||
@@ -17,11 +28,12 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
source?: AudioScheduledSourceNode;
|
||||
drawIntervalId?: number;
|
||||
}[] = [];
|
||||
const harness = window as unknown as WebRtcTestHarnessWindow;
|
||||
|
||||
(window as any).__rtcConnections = connections;
|
||||
(window as any).__rtcDataChannels = dataChannels;
|
||||
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
||||
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
|
||||
harness.__rtcConnections = connections;
|
||||
harness.__rtcDataChannels = dataChannels;
|
||||
harness.__rtcRemoteTracks = [];
|
||||
harness.__rtcSyntheticMediaResources = syntheticMediaResources;
|
||||
|
||||
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
||||
const trackDataChannel = (channel: RTCDataChannel) => {
|
||||
@@ -32,7 +44,7 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
dataChannels.push(channel);
|
||||
};
|
||||
|
||||
(window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) {
|
||||
harness.RTCPeerConnection = function(this: RTCPeerConnection, ...args: RtcPeerConnectionArgs) {
|
||||
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
|
||||
const originalCreateDataChannel = pc.createDataChannel.bind(pc);
|
||||
|
||||
@@ -46,7 +58,7 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
}) as RTCPeerConnection['createDataChannel'];
|
||||
|
||||
pc.addEventListener('connectionstatechange', () => {
|
||||
(window as any).__lastRtcState = pc.connectionState;
|
||||
harness.__lastRtcState = pc.connectionState;
|
||||
});
|
||||
|
||||
pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => {
|
||||
@@ -54,7 +66,7 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
});
|
||||
|
||||
pc.addEventListener('track', (event: RTCTrackEvent) => {
|
||||
(window as any).__rtcRemoteTracks.push({
|
||||
harness.__rtcRemoteTracks.push({
|
||||
kind: event.track.kind,
|
||||
id: event.track.id,
|
||||
readyState: event.track.readyState
|
||||
@@ -62,10 +74,10 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
});
|
||||
|
||||
return pc;
|
||||
} as any;
|
||||
} as typeof RTCPeerConnection;
|
||||
|
||||
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
||||
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
|
||||
harness.RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
||||
Object.setPrototypeOf(harness.RTCPeerConnection, OriginalRTCPeerConnection);
|
||||
|
||||
// Patch getDisplayMedia to return a synthetic screen share stream
|
||||
// (canvas-based video + 880Hz oscillator audio) so the browser
|
||||
@@ -140,10 +152,11 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
}, { once: true });
|
||||
|
||||
// Tag the stream so tests can identify it
|
||||
(resultStream as any).__isScreenShare = true;
|
||||
(resultStream as ScreenShareMediaStream).__isScreenShare = true;
|
||||
|
||||
return resultStream;
|
||||
};
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@@ -165,11 +178,12 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const OrigAudioContext = window.AudioContext;
|
||||
const audioHarness = window as unknown as WebRtcTestHarnessWindow;
|
||||
|
||||
(window as any).AudioContext = function(this: AudioContext, ...args: any[]) {
|
||||
audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
|
||||
const ctx: AudioContext = new OrigAudioContext(...args);
|
||||
// Track all created AudioContexts for test diagnostics
|
||||
const tracked = ((window as any).__trackedAudioContexts ??= []) as AudioContext[];
|
||||
const tracked = audioHarness.__trackedAudioContexts ??= [];
|
||||
|
||||
tracked.push(ctx);
|
||||
|
||||
@@ -185,18 +199,19 @@ export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
||||
});
|
||||
|
||||
return ctx;
|
||||
} as any;
|
||||
} as typeof AudioContext;
|
||||
|
||||
(window as any).AudioContext.prototype = OrigAudioContext.prototype;
|
||||
Object.setPrototypeOf((window as any).AudioContext, OrigAudioContext);
|
||||
audioHarness.AudioContext.prototype = OrigAudioContext.prototype;
|
||||
Object.setPrototypeOf(audioHarness.AudioContext, OrigAudioContext);
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => (window as any).__rtcConnections?.some(
|
||||
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||
) ?? false,
|
||||
undefined,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
@@ -206,7 +221,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis
|
||||
*/
|
||||
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
() => (window as any).__rtcConnections?.some(
|
||||
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||
) ?? false
|
||||
);
|
||||
@@ -215,7 +230,7 @@ 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(
|
||||
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||
(pc) => pc.connectionState === 'connected'
|
||||
).length ?? 0
|
||||
);
|
||||
@@ -223,19 +238,36 @@ export async function getConnectedPeerCount(page: Page): Promise<number> {
|
||||
|
||||
/** 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 }
|
||||
);
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||
(pc) => pc.connectionState === 'connected'
|
||||
).length === count,
|
||||
expectedCount,
|
||||
{ timeout }
|
||||
);
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(() => {
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections ?? [];
|
||||
|
||||
return {
|
||||
connected: connections.filter((pc) => pc.connectionState === 'connected').length,
|
||||
states: connections.map((pc) => pc.connectionState)
|
||||
};
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
`Expected ${expectedCount} connected peers within ${timeout}ms; `
|
||||
+ `saw ${diagnostics.connected} connected (${diagnostics.states.join(', ') || 'none'})`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(
|
||||
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||
(channel) => channel.readyState === 'open'
|
||||
).length ?? 0
|
||||
);
|
||||
@@ -244,7 +276,7 @@ export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
||||
/** 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(
|
||||
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||
(channel) => channel.readyState === 'open'
|
||||
).length === count,
|
||||
expectedCount,
|
||||
@@ -255,7 +287,7 @@ export async function waitForOpenDataChannelCount(page: Page, expectedCount: num
|
||||
/** 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) ?? [];
|
||||
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||
|
||||
let closed = 0;
|
||||
|
||||
@@ -275,7 +307,7 @@ export async function closeOpenDataChannels(page: Page): Promise<number> {
|
||||
/** 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) ?? [];
|
||||
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||
|
||||
let dispatched = 0;
|
||||
|
||||
@@ -336,7 +368,7 @@ interface PerPeerAudioStat {
|
||||
/** 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;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length) {
|
||||
return [];
|
||||
@@ -353,7 +385,7 @@ export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat
|
||||
try {
|
||||
const stats = await pc.getStats();
|
||||
|
||||
stats.forEach((report: any) => {
|
||||
stats.forEach((report: RTCStats) => {
|
||||
const kind = report.kind ?? report.mediaType;
|
||||
|
||||
if (report.type === 'outbound-rtp' && kind === 'audio') {
|
||||
@@ -454,7 +486,7 @@ export async function getAudioStats(page: Page): Promise<{
|
||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||
}> {
|
||||
return page.evaluate(async () => {
|
||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return { outbound: null, inbound: null };
|
||||
@@ -468,8 +500,8 @@ export async function getAudioStats(page: Page): Promise<{
|
||||
hasInbound: boolean;
|
||||
};
|
||||
|
||||
const hwm: Record<number, HWMEntry> = (window as any).__rtcStatsHWM =
|
||||
((window as any).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
||||
const hwm: Record<number, HWMEntry> = (window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM =
|
||||
((window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
||||
|
||||
for (let idx = 0; idx < connections.length; idx++) {
|
||||
let stats: RTCStatsReport;
|
||||
@@ -487,7 +519,7 @@ export async function getAudioStats(page: Page): Promise<{
|
||||
let hasOut = false;
|
||||
let hasIn = false;
|
||||
|
||||
stats.forEach((report: any) => {
|
||||
stats.forEach((report: RTCStats) => {
|
||||
const kind = report.kind ?? report.mediaType;
|
||||
|
||||
if (report.type === 'outbound-rtp' && kind === 'audio') {
|
||||
@@ -578,7 +610,7 @@ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promis
|
||||
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
async () => {
|
||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return false;
|
||||
@@ -595,7 +627,7 @@ export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Pr
|
||||
let hasOut = false;
|
||||
let hasIn = false;
|
||||
|
||||
stats.forEach((report: any) => {
|
||||
stats.forEach((report: RTCStats) => {
|
||||
const kind = report.kind ?? report.mediaType;
|
||||
|
||||
if (report.type === 'outbound-rtp' && kind === 'audio')
|
||||
@@ -611,6 +643,7 @@ export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Pr
|
||||
|
||||
return false;
|
||||
},
|
||||
undefined,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
@@ -686,7 +719,7 @@ export async function getVideoStats(page: Page): Promise<{
|
||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||
}> {
|
||||
return page.evaluate(async () => {
|
||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return { outbound: null, inbound: null };
|
||||
@@ -700,8 +733,8 @@ export async function getVideoStats(page: Page): Promise<{
|
||||
hasInbound: boolean;
|
||||
}
|
||||
|
||||
const hwm: Record<number, VHWM> = (window as any).__rtcVideoStatsHWM =
|
||||
((window as any).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
||||
const hwm: Record<number, VHWM> = (window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM =
|
||||
((window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
||||
|
||||
for (let idx = 0; idx < connections.length; idx++) {
|
||||
let stats: RTCStatsReport;
|
||||
@@ -719,7 +752,7 @@ export async function getVideoStats(page: Page): Promise<{
|
||||
let hasOut = false;
|
||||
let hasIn = false;
|
||||
|
||||
stats.forEach((report: any) => {
|
||||
stats.forEach((report: RTCStats) => {
|
||||
const kind = report.kind ?? report.mediaType;
|
||||
|
||||
if (report.type === 'outbound-rtp' && kind === 'video') {
|
||||
@@ -785,7 +818,7 @@ export async function getVideoStats(page: Page): Promise<{
|
||||
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
async () => {
|
||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return false;
|
||||
@@ -802,7 +835,7 @@ export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Pr
|
||||
let hasOut = false;
|
||||
let hasIn = false;
|
||||
|
||||
stats.forEach((report: any) => {
|
||||
stats.forEach((report: RTCStats) => {
|
||||
const kind = report.kind ?? report.mediaType;
|
||||
|
||||
if (report.type === 'outbound-rtp' && kind === 'video')
|
||||
@@ -818,6 +851,7 @@ export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Pr
|
||||
|
||||
return false;
|
||||
},
|
||||
undefined,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
@@ -952,7 +986,7 @@ export async function waitForInboundVideoFlow(
|
||||
*/
|
||||
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
||||
return page.evaluate(async () => {
|
||||
const conns = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const conns = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!conns?.length)
|
||||
return 'No connections tracked';
|
||||
@@ -977,7 +1011,7 @@ export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
||||
try {
|
||||
const stats = await pc.getStats();
|
||||
|
||||
stats.forEach((report: any) => {
|
||||
stats.forEach((report: RTCStats) => {
|
||||
if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp')
|
||||
return;
|
||||
|
||||
@@ -987,7 +1021,7 @@ export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
||||
|
||||
lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`);
|
||||
});
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
lines.push(` getStats() failed: ${err?.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface RtcRemoteTrackSnapshot {
|
||||
kind: string;
|
||||
id: string;
|
||||
readyState: string;
|
||||
}
|
||||
|
||||
export interface RtcSyntheticMediaResource {
|
||||
audioCtx: AudioContext;
|
||||
source?: AudioScheduledSourceNode;
|
||||
drawIntervalId?: number;
|
||||
}
|
||||
|
||||
export interface WebRtcTestHarnessWindow extends Window {
|
||||
__rtcConnections: RTCPeerConnection[];
|
||||
__rtcDataChannels: RTCDataChannel[];
|
||||
__rtcRemoteTracks: RtcRemoteTrackSnapshot[];
|
||||
__rtcSyntheticMediaResources: RtcSyntheticMediaResource[];
|
||||
__trackedAudioContexts?: AudioContext[];
|
||||
__rtcStatsHWM?: Record<number, Record<string, number | boolean>>;
|
||||
__rtcVideoStatsHWM?: Record<number, Record<string, number | boolean>>;
|
||||
__lastRtcState?: RTCPeerConnectionState;
|
||||
RTCPeerConnection: typeof RTCPeerConnection;
|
||||
AudioContext: typeof AudioContext;
|
||||
}
|
||||
|
||||
export function getWebRtcTestHarnessWindow(): WebRtcTestHarnessWindow {
|
||||
return window as unknown as WebRtcTestHarnessWindow;
|
||||
}
|
||||
@@ -34,9 +34,22 @@ export class ChatMessagesPage {
|
||||
}
|
||||
|
||||
async sendMessage(content: string): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill(content);
|
||||
await this.sendButton.click();
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill(content);
|
||||
await expect(this.composerInput).toHaveValue(content, { timeout: 5_000 });
|
||||
await expect(this.sendButton).toBeEnabled({ timeout: 5_000 });
|
||||
await this.sendButton.click();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error('Failed to send chat message');
|
||||
}
|
||||
|
||||
async typeDraft(content: string): Promise<void> {
|
||||
@@ -44,6 +57,13 @@ export class ChatMessagesPage {
|
||||
await this.composerInput.fill(content);
|
||||
}
|
||||
|
||||
/** Types into the composer in a way that emits input/typing events (not just fill). */
|
||||
async typeDraftWithTypingEvents(content: string): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.click();
|
||||
await this.composerInput.pressSequentially(content, { delay: 40 });
|
||||
}
|
||||
|
||||
async clearDraft(): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill('');
|
||||
@@ -74,6 +94,25 @@ export class ChatMessagesPage {
|
||||
}, files);
|
||||
}
|
||||
|
||||
/** Sends the currently-attached files with no text caption (attachment-only message). */
|
||||
async sendPendingAttachments(): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await expect(this.sendButton).toBeEnabled({ timeout: 10_000 });
|
||||
await this.sendButton.click();
|
||||
}
|
||||
|
||||
/** The message bubble that contains the rendered image with the given alt text. */
|
||||
getMessageItemContainingImage(altText: string): Locator {
|
||||
return this.messageItems.filter({
|
||||
has: this.page.locator(`img[alt="${altText}"]`)
|
||||
}).last();
|
||||
}
|
||||
|
||||
/** Resolves the stable data-message-id of the bubble holding the given image. */
|
||||
async getMessageIdContainingImage(altText: string): Promise<string | null> {
|
||||
return this.getMessageItemContainingImage(altText).getAttribute('data-message-id');
|
||||
}
|
||||
|
||||
async openGifPicker(): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.gifButton.click();
|
||||
@@ -112,6 +151,31 @@ export class ChatMessagesPage {
|
||||
}).toBe(true);
|
||||
}
|
||||
|
||||
/** SHA-256 of the bytes currently served by the rendered chat image. */
|
||||
async getMessageImageSha256(altText: string): Promise<string> {
|
||||
const image = this.getMessageImageByAlt(altText);
|
||||
|
||||
return image.evaluate(async (element) => {
|
||||
const img = element as HTMLImageElement;
|
||||
const response = await fetch(img.src);
|
||||
const buffer = await response.arrayBuffer();
|
||||
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
||||
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
});
|
||||
}
|
||||
|
||||
/** Asserts the rendered chat image is byte-identical to the sent file. */
|
||||
async expectMessageImageContentSha256(altText: string, expectedSha256: string): Promise<void> {
|
||||
await this.expectMessageImageLoaded(altText);
|
||||
await expect.poll(() => this.getMessageImageSha256(altText), {
|
||||
timeout: 30_000,
|
||||
message: `Image ${altText} should be received byte-identical (no truncated/corrupt transfer)`
|
||||
}).toBe(expectedSha256);
|
||||
}
|
||||
|
||||
getEmbedCardByTitle(title: string): Locator {
|
||||
return this.page.locator('app-chat-link-embed').filter({
|
||||
has: this.page.getByText(title, { exact: true })
|
||||
|
||||
@@ -10,15 +10,14 @@ export class LoginPage {
|
||||
readonly registerLink: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]')
|
||||
.first();
|
||||
this.form = page.locator('form').filter({ has: page.locator('#login-username') });
|
||||
|
||||
this.usernameInput = page.locator('#login-username');
|
||||
this.passwordInput = page.locator('#login-password');
|
||||
this.serverSelect = page.locator('#login-server');
|
||||
this.submitButton = this.form.getByRole('button', { name: 'Login' });
|
||||
this.errorText = page.locator('.text-destructive');
|
||||
this.registerLink = this.form.getByRole('button', { name: 'Register' });
|
||||
this.registerLink = page.getByRole('button', { name: 'Register' });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const e2eDirectory = fileURLToPath(new URL('.', import.meta.url));
|
||||
const env = { ...process.env };
|
||||
const browsersPath = env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
|
||||
if (browsersPath?.includes('/cursor-sandbox-cache/')) {
|
||||
delete env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
}
|
||||
|
||||
const [command = 'test', ...args] = process.argv.slice(2);
|
||||
const executable = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
const child = spawn(executable, ['playwright', command, ...args], {
|
||||
cwd: e2eDirectory,
|
||||
env,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { LoginPage } from '../../pages/login.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
|
||||
interface TestUser {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
test.describe('Login returnUrl handling', () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test('unwraps nested login returnUrl chains after successful login', async ({ createClient }) => {
|
||||
const client = await createClient();
|
||||
const { page } = client;
|
||||
const suffix = uniqueName('nested-return');
|
||||
const user: TestUser = {
|
||||
username: `user_${suffix}`,
|
||||
displayName: 'Return Url User',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
|
||||
await test.step('Create an account', async () => {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(user.username, user.displayName, user.password);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('Log out and open a deeply nested login returnUrl', async () => {
|
||||
await logout(page);
|
||||
|
||||
const nestedReturnUrl = '/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers';
|
||||
|
||||
await page.goto(`/login?returnUrl=${encodeURIComponent(nestedReturnUrl)}`, {
|
||||
waitUntil: 'domcontentloaded'
|
||||
});
|
||||
|
||||
await expect(page.locator('#login-username')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('Login lands on the original destination instead of looping on /login', async () => {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.login(user.username, user.password);
|
||||
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
|
||||
await expect(page).not.toHaveURL(/returnUrl=.*login/);
|
||||
});
|
||||
});
|
||||
|
||||
test('redirects unauthenticated /servers visits to login and returns there after login', async ({ createClient }) => {
|
||||
const client = await createClient();
|
||||
const { page } = client;
|
||||
const suffix = uniqueName('servers-return');
|
||||
const user: TestUser = {
|
||||
username: `user_${suffix}`,
|
||||
displayName: 'Servers Return User',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
|
||||
await test.step('Create an account and log out', async () => {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(user.username, user.displayName, user.password);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
await logout(page);
|
||||
});
|
||||
|
||||
await test.step('Visiting /servers sends the user to a single-level login returnUrl', async () => {
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
|
||||
await expect(page).not.toHaveURL(/returnUrl=.*login/);
|
||||
});
|
||||
|
||||
await test.step('Logging in returns to /servers', async () => {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.login(user.username, user.password);
|
||||
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
|
||||
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('lets a returning user log back in after an expired session redirect', async ({ createClient }) => {
|
||||
const client = await createClient();
|
||||
const { page } = client;
|
||||
const suffix = uniqueName('expired-session');
|
||||
const user: TestUser = {
|
||||
username: `user_${suffix}`,
|
||||
displayName: 'Expired Session User',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
|
||||
await test.step('Create an account', async () => {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(user.username, user.displayName, user.password);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('Simulate an expired session while keeping the persisted user id', async () => {
|
||||
await page.evaluate(() => {
|
||||
const storageKey = 'metoyou.authTokens';
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Record<string, { token: string; expiresAt: number }>;
|
||||
const expiredStore = Object.fromEntries(
|
||||
Object.entries(parsed).map(([url, entry]) => [url, { ...entry, expiresAt: 0 }])
|
||||
);
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(expiredStore));
|
||||
});
|
||||
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
|
||||
await expect(page).not.toHaveURL(/returnUrl=.*login/);
|
||||
});
|
||||
|
||||
await test.step('The user can authenticate again and reach /servers', async () => {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.login(user.username, user.password);
|
||||
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
|
||||
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function logout(page: import('@playwright/test').Page): Promise<void> {
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import {
|
||||
MULTI_DEVICE_VOICE_CHANNEL,
|
||||
channelsSidePanel,
|
||||
createMultiDeviceScenario,
|
||||
expectCrossDeviceMessage,
|
||||
expectActiveVoiceOnDevice,
|
||||
expectPassiveVoiceOnDevice,
|
||||
logoutFromMenu,
|
||||
membersSidePanel,
|
||||
passiveVoiceChannelJoinBadge,
|
||||
readClientInstanceId,
|
||||
uniqueMultiDeviceName
|
||||
} from '../../helpers/multi-device-session';
|
||||
|
||||
test.describe('Multi-device session', () => {
|
||||
test.describe.configure({ timeout: 300_000, retries: 1 });
|
||||
|
||||
test('covers identity, chat sync, typing exclusion, and voice exclusivity', async ({ createClient }) => {
|
||||
const scenario = await createMultiDeviceScenario(createClient);
|
||||
const messageAtoB = `Cross-device A to B ${uniqueMultiDeviceName('msg')}`;
|
||||
const messageBtoA = `Cross-device B to A ${uniqueMultiDeviceName('msg')}`;
|
||||
const typingDraft = `Typing draft ${uniqueMultiDeviceName('draft')}`;
|
||||
|
||||
await test.step('assigns distinct clientInstanceId per browser context', async () => {
|
||||
const instanceA = await readClientInstanceId(scenario.clientA.page);
|
||||
const instanceB = await readClientInstanceId(scenario.clientB.page);
|
||||
|
||||
expect(instanceA).toBeTruthy();
|
||||
expect(instanceB).toBeTruthy();
|
||||
expect(instanceA).not.toEqual(instanceB);
|
||||
});
|
||||
|
||||
await test.step('shows one self identity in the members panel on each device', async () => {
|
||||
for (const client of [scenario.clientA, scenario.clientB]) {
|
||||
await expect(
|
||||
membersSidePanel(client.page).getByText(scenario.credentials.displayName, { exact: true })
|
||||
).toHaveCount(1, { timeout: 20_000 });
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('syncs chat from device A to device B', async () => {
|
||||
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
||||
});
|
||||
|
||||
await test.step('syncs chat from device B to device A', async () => {
|
||||
await expectCrossDeviceMessage(scenario.messagesB, scenario.messagesA, messageBtoA);
|
||||
});
|
||||
|
||||
await test.step('does not show own typing indicator on the other device for the same user', async () => {
|
||||
await scenario.messagesA.typeDraftWithTypingEvents(typingDraft);
|
||||
|
||||
await expect(
|
||||
scenario.clientB.page.getByText(`${scenario.credentials.displayName} is typing`, { exact: false })
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
});
|
||||
|
||||
await test.step('shows passive in-voice UI on the second device when the first joins voice', async () => {
|
||||
await scenario.roomA.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
await expectActiveVoiceOnDevice(scenario.clientA.page);
|
||||
|
||||
await expectPassiveVoiceOnDevice(scenario.clientB.page, {
|
||||
displayName: scenario.credentials.displayName
|
||||
});
|
||||
|
||||
await expect(
|
||||
membersSidePanel(scenario.clientB.page).getByText('In voice on another device', { exact: false })
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect(
|
||||
channelsSidePanel(scenario.clientB.page).locator('.opacity-50')
|
||||
.filter({
|
||||
hasText: scenario.credentials.displayName
|
||||
})
|
||||
.first()
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('shows Join takeover affordance on passive device voice channel', async () => {
|
||||
await expect(passiveVoiceChannelJoinBadge(scenario.clientB.page)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('transfers voice ownership when the passive device takes over', async () => {
|
||||
await scenario.roomB.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
await expectActiveVoiceOnDevice(scenario.clientB.page);
|
||||
|
||||
await expectPassiveVoiceOnDevice(scenario.clientA.page, {
|
||||
displayName: scenario.credentials.displayName
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('keeps the second device logged in when the first device logs out', async () => {
|
||||
const message = `Still logged in ${uniqueMultiDeviceName('logout')}`;
|
||||
|
||||
await logoutFromMenu(scenario.clientA.page);
|
||||
|
||||
await scenario.messagesB.sendMessage(message);
|
||||
await expect(scenario.messagesB.getMessageItemByText(message)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.clientB.page).toHaveURL(/\/room\//, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../../fixtures/multi-client';
|
||||
import { openSettingsFromMenu } from '../../helpers/app-menu';
|
||||
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
|
||||
import { startTestServer } from '../../helpers/test-server';
|
||||
import {
|
||||
readAuthTokenFromPage,
|
||||
readSignalServerCredentialFromPage,
|
||||
registerTestUser
|
||||
} from '../../helpers/auth-api';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
|
||||
const PRIMARY_ENDPOINT_ID = 'e2e-multi-auth-primary';
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
|
||||
test.describe('Multi-signal-server authentication', () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test('auto-provisions a foreign signal server when a new endpoint is added', async ({ createClient, request }) => {
|
||||
const primaryServer = await startTestServer();
|
||||
const secondaryServer = await startTestServer();
|
||||
|
||||
try {
|
||||
const client = await createClient();
|
||||
const suffix = `multi_auth_${Date.now()}`;
|
||||
const username = `user_${suffix}`;
|
||||
|
||||
await installTestServerEndpoints(client.context, [
|
||||
{
|
||||
id: PRIMARY_ENDPOINT_ID,
|
||||
name: 'E2E Primary Signal',
|
||||
url: primaryServer.url,
|
||||
isActive: true,
|
||||
status: 'online'
|
||||
}
|
||||
]);
|
||||
|
||||
await test.step('Register on the home signal server', async () => {
|
||||
const register = new RegisterPage(client.page);
|
||||
|
||||
await register.goto();
|
||||
await register.register(username, 'Multi Auth User', USER_PASSWORD);
|
||||
await expectDashboardReady(client.page);
|
||||
});
|
||||
|
||||
await test.step('Add a second signal server in network settings', async () => {
|
||||
await openSettingsFromMenu(client.page);
|
||||
await client.page.getByRole('button', { name: 'Network' }).click();
|
||||
|
||||
await client.page.getByPlaceholder('Server name').fill('E2E Secondary Signal');
|
||||
await client.page.getByPlaceholder('Server URL (e.g., http://localhost:3001)').fill(secondaryServer.url);
|
||||
await client.page.getByTestId('add-signal-server-button').click();
|
||||
|
||||
await expect(client.page.getByText(secondaryServer.url)).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('Wait for auto-provisioned credentials on the secondary server', async () => {
|
||||
await expect.poll(async () =>
|
||||
await readSignalServerCredentialFromPage(client.page, secondaryServer.url),
|
||||
{ timeout: 30_000 }
|
||||
).not.toBeNull();
|
||||
|
||||
const homeToken = await readAuthTokenFromPage(client.page, primaryServer.url);
|
||||
const secondaryCredential = await readSignalServerCredentialFromPage(client.page, secondaryServer.url);
|
||||
|
||||
expect(homeToken).toBeTruthy();
|
||||
expect(secondaryCredential?.username).toBe(username);
|
||||
expect(secondaryCredential?.token).toBeTruthy();
|
||||
});
|
||||
|
||||
await test.step('Secondary credential can call authenticated APIs', async () => {
|
||||
const secondaryCredential = await readSignalServerCredentialFromPage(client.page, secondaryServer.url);
|
||||
|
||||
if (!secondaryCredential) {
|
||||
throw new Error('Expected secondary signal-server credential to be provisioned');
|
||||
}
|
||||
|
||||
const response = await request.post(`${secondaryServer.url}/api/servers`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${secondaryCredential.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: {
|
||||
name: `Secondary Provisioned Server ${suffix}`,
|
||||
description: 'Created with auto-provisioned credentials',
|
||||
ownerId: secondaryCredential.userId,
|
||||
ownerPublicKey: 'e2e-secondary-owner-key'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.ok(), `POST /api/servers failed: ${response.status()} ${await response.text()}`).toBe(true);
|
||||
});
|
||||
|
||||
await test.step('Home registration still works independently on the secondary server', async () => {
|
||||
const otherUser = await registerTestUser(
|
||||
request,
|
||||
secondaryServer.url,
|
||||
`other_${suffix}`,
|
||||
USER_PASSWORD,
|
||||
'Other User'
|
||||
);
|
||||
|
||||
expect(otherUser.username).toBe(`other_${suffix}`);
|
||||
});
|
||||
} finally {
|
||||
await primaryServer.stop();
|
||||
await secondaryServer.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../../fixtures/multi-client';
|
||||
import { openSettingsFromMenu } from '../../helpers/app-menu';
|
||||
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
|
||||
import { startTestServer } from '../../helpers/test-server';
|
||||
import { readSignalServerCredentialFromPage, SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY } from '../../helpers/auth-api';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
|
||||
const PRIMARY_ENDPOINT_ID = 'e2e-offline-login-primary';
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
|
||||
test.describe('Offline signal server navigation', () => {
|
||||
test('does not redirect to authorize login after a foreign server goes offline', async ({ createClient }) => {
|
||||
const primaryServer = await startTestServer();
|
||||
const secondaryServer = await startTestServer();
|
||||
const suffix = `offline_login_${Date.now()}`;
|
||||
const username = `user_${suffix}`;
|
||||
|
||||
try {
|
||||
const client = await createClient();
|
||||
|
||||
await installTestServerEndpoints(client.context, [
|
||||
{
|
||||
id: PRIMARY_ENDPOINT_ID,
|
||||
name: 'E2E Primary Signal',
|
||||
url: primaryServer.url,
|
||||
isActive: true,
|
||||
status: 'online'
|
||||
}
|
||||
]);
|
||||
|
||||
await test.step('Register and provision a secondary signal server', async () => {
|
||||
const register = new RegisterPage(client.page);
|
||||
|
||||
await register.goto();
|
||||
await register.register(username, 'Offline Login User', USER_PASSWORD);
|
||||
await expectDashboardReady(client.page);
|
||||
|
||||
await openSettingsFromMenu(client.page);
|
||||
await client.page.getByRole('button', { name: 'Network' }).click();
|
||||
await client.page.getByPlaceholder('Server name').fill('E2E Secondary Signal');
|
||||
await client.page.getByPlaceholder('Server URL (e.g., http://localhost:3001)').fill(secondaryServer.url);
|
||||
await client.page.getByTestId('add-signal-server-button').click();
|
||||
|
||||
await expect(client.page.getByText(secondaryServer.url)).toBeVisible({ timeout: 15_000 });
|
||||
await expect.poll(async () =>
|
||||
await readSignalServerCredentialFromPage(client.page, secondaryServer.url),
|
||||
{ timeout: 30_000 }
|
||||
).not.toBeNull();
|
||||
|
||||
await client.page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
await test.step('Offline secondary endpoints do not trigger authorize login', async () => {
|
||||
await secondaryServer.stop();
|
||||
|
||||
await client.page.evaluate(({ storageKey, url }) => {
|
||||
const normalizedUrl = url.trim().replace(/\/+$/, '');
|
||||
const credentialStore = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, unknown>;
|
||||
const nextCredentialStore = Object.fromEntries(
|
||||
Object.entries(credentialStore).filter(([key]) => key !== normalizedUrl)
|
||||
);
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(nextCredentialStore));
|
||||
|
||||
const endpoints = JSON.parse(localStorage.getItem('metoyou_server_endpoints') || '[]') as {
|
||||
url: string;
|
||||
status: string;
|
||||
}[];
|
||||
|
||||
localStorage.setItem('metoyou_server_endpoints', JSON.stringify(endpoints.map((endpoint) =>
|
||||
endpoint.url.trim().replace(/\/+$/, '') === normalizedUrl
|
||||
? { ...endpoint, status: 'offline' }
|
||||
: endpoint
|
||||
)));
|
||||
}, { storageKey: SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY, url: secondaryServer.url });
|
||||
|
||||
await client.page.goto('/dashboard', { waitUntil: 'commit', timeout: 10_000 });
|
||||
await expect(client.page).not.toHaveURL(/\/login/);
|
||||
await expect(client.page.url()).not.toMatch(/mode=authorize/);
|
||||
});
|
||||
} finally {
|
||||
await primaryServer.stop();
|
||||
await secondaryServer.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -48,14 +48,13 @@ test.describe('User session data isolation', () => {
|
||||
|
||||
await test.step('Alice registers and creates local chat history', async () => {
|
||||
await registerUser(client.page, alice);
|
||||
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
|
||||
await createServerAndSendMessage(client.page, alice, 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);
|
||||
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
||||
});
|
||||
} finally {
|
||||
await closePersistentClient(client);
|
||||
@@ -88,11 +87,11 @@ test.describe('User session data isolation', () => {
|
||||
|
||||
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 createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
|
||||
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
||||
});
|
||||
|
||||
await test.step('Bob starts from a blank slate in the same browser profile', async () => {
|
||||
@@ -102,11 +101,11 @@ test.describe('User session data isolation', () => {
|
||||
});
|
||||
|
||||
await test.step('Bob gets only his own saved room and history after a restart', async () => {
|
||||
await createServerAndSendMessage(client.page, bobServerName, bobMessage);
|
||||
await createServerAndSendMessage(client.page, bob, bobServerName, bobMessage);
|
||||
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expectSavedRoomAndHistory(client.page, bobServerName, bobMessage);
|
||||
await expectSavedRoomAndHistory(client.page, bob, bobServerName, bobMessage);
|
||||
await expectSavedRoomHidden(client.page, aliceServerName);
|
||||
});
|
||||
|
||||
@@ -117,7 +116,7 @@ test.describe('User session data isolation', () => {
|
||||
|
||||
await expectSavedRoomVisible(client.page, aliceServerName);
|
||||
await expectSavedRoomHidden(client.page, bobServerName);
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
||||
});
|
||||
} finally {
|
||||
await closePersistentClient(client);
|
||||
@@ -194,32 +193,58 @@ async function logoutUser(page: Page): Promise<void> {
|
||||
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise<void> {
|
||||
async function createServerAndSendMessage(page: Page, user: TestUser, 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 loginIfNeeded(page, user);
|
||||
await ensureCurrentUserScope(page, user);
|
||||
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
if (await waitForLoginForm(page, 5_000)) {
|
||||
await loginUser(page, user);
|
||||
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
|
||||
await expect(searchPage.serverNameInput).toBeVisible({ timeout: 10_000 });
|
||||
await searchPage.serverNameInput.fill(serverName);
|
||||
await searchPage.serverDescriptionInput.fill(`User session isolation coverage for ${serverName}`);
|
||||
await searchPage.createSubmitButton.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
await messagesPage.sendMessage(messageText);
|
||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
||||
await expectMessagePersistedInIndexedDb(page, messageText);
|
||||
}
|
||||
|
||||
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> {
|
||||
const railRoomButton = getRailSavedRoomButton(page, roomName);
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
async function expectSavedRoomAndHistory(page: Page, user: TestUser, roomName: string, messageText: string): Promise<void> {
|
||||
if (await waitForVisibleText(page, messageText, 5_000)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(railRoomButton).toBeVisible({ timeout: 20_000 });
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
const searchRoomButton = getSearchSavedRoomButton(page, roomName);
|
||||
if (await new LoginPage(page).usernameInput.isVisible().catch(() => false)) {
|
||||
await loginUser(page, user);
|
||||
}
|
||||
|
||||
await expect(searchRoomButton).toBeVisible({ timeout: 20_000 });
|
||||
await searchRoomButton.click();
|
||||
await expectMessagePersistedInIndexedDb(page, messageText);
|
||||
|
||||
const persistedRoomId = await getPersistedRoomIdForMessage(page, messageText);
|
||||
|
||||
if (persistedRoomId) {
|
||||
await openPersistedRoomById(page, user, persistedRoomId);
|
||||
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (await openSavedRoomFromRail(page, roomName)) {
|
||||
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
await joinServerFromSearchAfterLogin(page, user, roomName);
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
|
||||
@@ -232,14 +257,17 @@ async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<
|
||||
}
|
||||
|
||||
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
|
||||
await expect(getRailSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
||||
if (await page.getByText(roomName, { exact: false }).first()
|
||||
.isVisible()
|
||||
.catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto('/servers', { 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('/servers')) {
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
@@ -247,14 +275,227 @@ async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void
|
||||
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-browser').getByRole('button', { name: roomName, exact: true });
|
||||
}
|
||||
|
||||
async function openSavedRoomFromRail(page: Page, roomName: string): Promise<boolean> {
|
||||
try {
|
||||
await expect(page.locator('app-servers-rail')).toBeVisible({ timeout: 10_000 });
|
||||
const clicked = await page.locator('app-servers-rail button').evaluateAll((buttons, expectedName) => {
|
||||
const expectedPrefix = expectedName.slice(0, 24);
|
||||
const button = buttons.find((candidate) => {
|
||||
const title = (candidate as HTMLButtonElement).title;
|
||||
|
||||
return title === expectedName || title.startsWith(expectedPrefix);
|
||||
}) as HTMLButtonElement | undefined;
|
||||
|
||||
button?.click();
|
||||
return !!button;
|
||||
}, roomName);
|
||||
|
||||
if (!clicked) {
|
||||
return await openSavedRoomFromDashboard(page, roomName);
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return await openSavedRoomFromDashboard(page, roomName);
|
||||
}
|
||||
}
|
||||
|
||||
async function openSavedRoomFromDashboard(page: Page, roomName: string): Promise<boolean> {
|
||||
const roomNamePattern = new RegExp(escapeRegExp(roomName.slice(0, 24)));
|
||||
const roomButton = page.getByRole('button', { name: roomNamePattern }).first();
|
||||
|
||||
try {
|
||||
await expect(roomButton).toBeVisible({ timeout: 10_000 });
|
||||
await roomButton.click();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return await joinVisibleServerFromDashboard(page, roomNamePattern);
|
||||
}
|
||||
}
|
||||
|
||||
async function joinVisibleServerFromDashboard(page: Page, roomNamePattern: RegExp): Promise<boolean> {
|
||||
const serverRow = page.locator('div', { hasText: roomNamePattern }).filter({
|
||||
has: page.getByRole('button', { name: 'Join' })
|
||||
})
|
||||
.last();
|
||||
const joinButton = serverRow.getByRole('button', { name: 'Join' });
|
||||
|
||||
try {
|
||||
await expect(joinButton).toBeVisible({ timeout: 10_000 });
|
||||
await joinButton.click();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function joinServerFromSearchAfterLogin(page: Page, user: TestUser, roomName: string): Promise<void> {
|
||||
const searchPage = new ServerSearchPage(page);
|
||||
|
||||
await loginIfNeeded(page, user);
|
||||
await searchPage.goto();
|
||||
|
||||
if (!await waitForServerSearch(page, 5_000)) {
|
||||
await loginUser(page, user);
|
||||
await searchPage.goto();
|
||||
}
|
||||
|
||||
await expect(searchPage.searchInput).toBeVisible({ timeout: 15_000 });
|
||||
await searchPage.searchInput.fill(roomName);
|
||||
|
||||
const serverCard = page.locator('div[title]', { hasText: roomName }).first();
|
||||
|
||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||
await serverCard.dblclick();
|
||||
}
|
||||
|
||||
async function loginIfNeeded(page: Page, user: TestUser): Promise<void> {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
if (page.url().includes('/login')) {
|
||||
await expect(loginPage.usernameInput).toBeVisible({ timeout: 15_000 });
|
||||
await loginUser(page, user);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await loginPage.usernameInput.isVisible().catch(() => false)) {
|
||||
await loginUser(page, user);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCurrentUserScope(page: Page, user: TestUser): Promise<void> {
|
||||
if (await hasCurrentUserScope(page)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loginUser(page, user);
|
||||
await expect.poll(() => hasCurrentUserScope(page), { timeout: 10_000 }).toBe(true);
|
||||
}
|
||||
|
||||
async function hasCurrentUserScope(page: Page): Promise<boolean> {
|
||||
return page.evaluate(() => !!localStorage.getItem('metoyou_currentUserId')?.trim());
|
||||
}
|
||||
|
||||
async function openPersistedRoomById(page: Page, user: TestUser, roomId: string): Promise<void> {
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
if (await waitForLoginForm(page, 5_000)) {
|
||||
await loginUser(page, user);
|
||||
continue;
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
if (!await waitForLoginForm(page, 2_000)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loginUser(page, user);
|
||||
}
|
||||
|
||||
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function waitForLoginForm(page: Page, timeout: number): Promise<boolean> {
|
||||
try {
|
||||
await expect(new LoginPage(page).usernameInput).toBeVisible({ timeout });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServerSearch(page: Page, timeout: number): Promise<boolean> {
|
||||
try {
|
||||
await expect(new ServerSearchPage(page).searchInput).toBeVisible({ timeout });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForVisibleText(page: Page, text: string, timeout: number): Promise<boolean> {
|
||||
try {
|
||||
await expect(page.getByText(text, { exact: false })).toBeVisible({ timeout });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectMessagePersistedInIndexedDb(page: Page, messageText: string): Promise<void> {
|
||||
await expect.poll(
|
||||
() => getPersistedRoomIdForMessage(page, messageText).then((roomId) => !!roomId),
|
||||
{ timeout: 10_000 }
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
async function getPersistedRoomIdForMessage(page: Page, messageText: string): Promise<string | null> {
|
||||
return page.evaluate(async (expectedContent) => {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim();
|
||||
const preferredDatabaseName = `metoyou::${encodeURIComponent(currentUserId || 'anonymous')}`;
|
||||
const discoveredDatabaseNames = typeof indexedDB.databases === 'function'
|
||||
? (await indexedDB.databases())
|
||||
.map((database) => database.name)
|
||||
.filter((name): name is string => !!name && (name === 'metoyou' || name.startsWith('metoyou::')))
|
||||
: null;
|
||||
const databaseNames = discoveredDatabaseNames ?? [preferredDatabaseName];
|
||||
const remainingDatabaseNames = databaseNames.filter((name) => name !== preferredDatabaseName);
|
||||
const orderedDatabaseNames = databaseNames.includes(preferredDatabaseName)
|
||||
? [preferredDatabaseName].concat(remainingDatabaseNames)
|
||||
: remainingDatabaseNames;
|
||||
|
||||
for (const databaseName of orderedDatabaseNames) {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
|
||||
try {
|
||||
if (!database.objectStoreNames.contains('messages')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transaction = database.transaction('messages', 'readonly');
|
||||
const request = transaction.objectStore('messages').getAll();
|
||||
const roomId = await new Promise<string | null>((resolve, reject) => {
|
||||
request.onerror = () => reject(request.error);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const match = ((request.result as { content?: string; roomId?: string }[]) ?? [])
|
||||
.find((message) => message.content === expectedContent);
|
||||
|
||||
resolve(match?.roomId ?? null);
|
||||
};
|
||||
});
|
||||
|
||||
if (roomId) {
|
||||
return roomId;
|
||||
}
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, messageText);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
type Client
|
||||
} from '../../fixtures/multi-client';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
|
||||
|
||||
/**
|
||||
* Regression coverage for: "Video attachment on android gets sent in the
|
||||
* message bubble above with no preview image."
|
||||
*
|
||||
* Root cause was platform-agnostic: caption-less media was bound to a message
|
||||
* re-discovered by matching `content` (always '' for attachment-only sends),
|
||||
* which raced the async create-effect and grouped a second attachment onto the
|
||||
* previous bubble - leaving an empty message behind. The fix pre-allocates the
|
||||
* message id, dispatches it, and binds attachments to that exact id. This test
|
||||
* proves each caption-less attachment lands in its own bubble and renders.
|
||||
*/
|
||||
test.describe('Attachment-only message grouping', () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test('each caption-less attachment keeps its own message bubble and preview', async ({ createClient }) => {
|
||||
const scenario = await createSingleClientChatScenario(createClient);
|
||||
const serverName = `Attachment Group ${uniqueName('srv')}`;
|
||||
const introText = `Intro line ${uniqueName('intro')}`;
|
||||
const firstImageName = `${uniqueName('first')}.svg`;
|
||||
const secondImageName = `${uniqueName('second')}.svg`;
|
||||
const firstImage = createSvgFilePayload(firstImageName);
|
||||
const secondImage = createSvgFilePayload(secondImageName);
|
||||
|
||||
await test.step('Create a server and open its room', async () => {
|
||||
await scenario.search.createServer(serverName, { description: 'Attachment grouping regression server' });
|
||||
await expect(scenario.client.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
await scenario.messages.waitForReady();
|
||||
});
|
||||
|
||||
await test.step('Send a normal text message first', async () => {
|
||||
await scenario.messages.sendMessage(introText);
|
||||
await expect(scenario.messages.getMessageItemByText(introText)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Send two caption-less attachments back-to-back', async () => {
|
||||
// Fire them rapidly (no render wait between) to mirror the reported
|
||||
// rapid-upload repro and stress the message-create vs. attach ordering.
|
||||
await scenario.messages.attachFiles([firstImage]);
|
||||
await scenario.messages.sendPendingAttachments();
|
||||
await scenario.messages.attachFiles([secondImage]);
|
||||
await scenario.messages.sendPendingAttachments();
|
||||
|
||||
await scenario.messages.expectMessageImageLoaded(firstImageName);
|
||||
await scenario.messages.expectMessageImageLoaded(secondImageName);
|
||||
});
|
||||
|
||||
await test.step('Each attachment lives in its own bubble (no grouping, no blank message)', async () => {
|
||||
const firstMessageId = await scenario.messages.getMessageIdContainingImage(firstImageName);
|
||||
const secondMessageId = await scenario.messages.getMessageIdContainingImage(secondImageName);
|
||||
|
||||
expect(firstMessageId).toBeTruthy();
|
||||
expect(secondMessageId).toBeTruthy();
|
||||
// The bug grouped both onto one bubble; distinct ids prove they did not.
|
||||
expect(firstMessageId).not.toBe(secondMessageId);
|
||||
|
||||
// Exactly two bubbles carry an image, and neither carries both.
|
||||
await expect(scenario.messages.messageItems.filter({ has: scenario.client.page.locator('img[alt$=".svg"]') }))
|
||||
.toHaveCount(2, { timeout: 20_000 });
|
||||
|
||||
await expect(scenario.messages.getMessageItemContainingImage(firstImageName).locator('img[alt$=".svg"]'))
|
||||
.toHaveCount(1);
|
||||
|
||||
await expect(scenario.messages.getMessageItemContainingImage(secondImageName).locator('img[alt$=".svg"]'))
|
||||
.toHaveCount(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface SingleClientChatScenario {
|
||||
client: Client;
|
||||
messages: ChatMessagesPage;
|
||||
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!'
|
||||
};
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
|
||||
|
||||
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
return {
|
||||
client,
|
||||
messages: new ChatMessagesPage(client.page),
|
||||
search: new ServerSearchPage(client.page)
|
||||
};
|
||||
}
|
||||
|
||||
function createSvgFilePayload(name: string): ChatDropFilePayload {
|
||||
const markup = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
|
||||
'<rect width="160" height="120" rx="18" fill="#0f172a" />',
|
||||
'<circle cx="38" cy="36" r="18" fill="#38bdf8" />',
|
||||
`<text x="24" y="104" fill="#e2e8f0" font-size="12" font-family="Arial, sans-serif">${name}</text>`,
|
||||
'</svg>'
|
||||
].join('');
|
||||
|
||||
return {
|
||||
name,
|
||||
mimeType: 'image/svg+xml',
|
||||
base64: Buffer.from(markup, 'utf8').toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { type Page } from '@playwright/test';
|
||||
import {
|
||||
test,
|
||||
@@ -182,6 +183,28 @@ test.describe('Chat messaging features', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('syncs multi-chunk image attachments byte-identical between users', async ({ createClient }) => {
|
||||
const scenario = await createChatScenario(createClient);
|
||||
const imageName = `${uniqueName('photo')}.svg`;
|
||||
const imageCaption = `Large image upload ${uniqueName('caption')}`;
|
||||
// Several P2P file chunks (64 KiB each) - regression coverage for transfers
|
||||
// that previously finalized with only the first chunks received.
|
||||
const { payload, sha256 } = createMultiChunkImagePayload(imageName);
|
||||
|
||||
await test.step('Alice sends a multi-chunk image attachment', async () => {
|
||||
await scenario.aliceMessages.attachFiles([payload]);
|
||||
await scenario.aliceMessages.sendMessage(imageCaption);
|
||||
|
||||
await scenario.aliceMessages.expectMessageImageLoaded(imageName);
|
||||
await scenario.aliceMessages.expectMessageImageContentSha256(imageName, sha256);
|
||||
});
|
||||
|
||||
await test.step('Bob receives the image fully and byte-identical', async () => {
|
||||
await expect(scenario.bobMessages.getMessageItemByText(imageCaption)).toBeVisible({ timeout: 20_000 });
|
||||
await scenario.bobMessages.expectMessageImageContentSha256(imageName, sha256);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders link embeds for shared links', async ({ createClient }) => {
|
||||
const scenario = await createChatScenario(createClient);
|
||||
const messageText = `Useful docs ${MOCK_EMBED_URL}`;
|
||||
@@ -442,6 +465,24 @@ function createTextFilePayload(name: string, mimeType: string, content: string):
|
||||
};
|
||||
}
|
||||
|
||||
function createMultiChunkImagePayload(name: string): { payload: ChatDropFilePayload; sha256: string } {
|
||||
// ~300 KB of XML-safe noise inside an SVG comment so the file spans
|
||||
// multiple 64 KiB P2P transfer chunks while remaining a renderable image.
|
||||
const noise = randomBytes(225_000).toString('base64');
|
||||
const markup = buildMockSvgMarkup(name).replace('</svg>', `<!-- ${noise} --></svg>`);
|
||||
const contentBuffer = Buffer.from(markup, 'utf8');
|
||||
|
||||
return {
|
||||
payload: {
|
||||
name,
|
||||
mimeType: 'image/svg+xml',
|
||||
base64: contentBuffer.toString('base64')
|
||||
},
|
||||
sha256: createHash('sha256').update(contentBuffer)
|
||||
.digest('hex')
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockSvgMarkup(label: string): string {
|
||||
return [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
type Page
|
||||
} from '@playwright/test';
|
||||
import { test as multiClientTest } from '../../fixtures/multi-client';
|
||||
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 {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression coverage for: "Emojis should be user bound not client bound".
|
||||
*
|
||||
* A custom emoji belongs to the user who saved it, not to the client. A second
|
||||
* account signing in on the same client must NOT inherit the first user's emoji
|
||||
* library/picker.
|
||||
*
|
||||
* The whole scenario runs in a SINGLE page load (only the very first navigation
|
||||
* reloads). All user switching is client-side via the router, because the leak
|
||||
* lived in the long-lived singleton CustomEmojiService that used to keep the
|
||||
* previous user's library after a logout + login without a reload. To avoid the
|
||||
* (separate) in-session "create a second server" limitation, the second user
|
||||
* joins the first user's server rather than creating their own.
|
||||
*/
|
||||
|
||||
// Minimal valid 1x1 transparent GIF; the emoji pipeline validates mime + size only.
|
||||
const TINY_GIF = Buffer.from(
|
||||
'47494638396101000100800000000000ffffff21f90401000000002c00000000010001000002024401003b',
|
||||
'hex'
|
||||
);
|
||||
|
||||
multiClientTest.describe('Custom emoji are user bound, not client bound', () => {
|
||||
multiClientTest.describe.configure({ timeout: 180_000 });
|
||||
|
||||
multiClientTest('a second user on the same client does not inherit the first user library', async ({ createClient }) => {
|
||||
const { page } = await createClient();
|
||||
const suffix = uniqueName('emoji-bound');
|
||||
const alice: TestUser = { username: `alice_${suffix}`, displayName: 'Alice', password: 'TestPass123!' };
|
||||
const bob: TestUser = { username: `bob_${suffix}`, displayName: 'Bob', password: 'TestPass123!' };
|
||||
const serverName = `Shared Emoji Server ${suffix}`;
|
||||
const libraryEmoji = page.locator('app-custom-emoji-picker [data-custom-emoji-library]');
|
||||
|
||||
await test.step('Alice registers, creates a server and uploads a custom emoji', async () => {
|
||||
await new RegisterPage(page).goto();
|
||||
await submitRegistration(page, alice);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
await createServer(page, serverName);
|
||||
await openComposerEmojiModal(page);
|
||||
await page.locator('app-custom-emoji-picker input[type="file"]').setInputFiles({
|
||||
name: `partyblob_${suffix}.gif`,
|
||||
mimeType: 'image/gif',
|
||||
buffer: TINY_GIF
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Alice sees her own uploaded emoji in her library', async () => {
|
||||
await openComposerEmojiModal(page);
|
||||
await expect(libraryEmoji).toHaveCount(1, { timeout: 15_000 });
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
await test.step('Bob signs in on the same client (no reload) and joins the same server', async () => {
|
||||
await logoutClientSide(page);
|
||||
await registerClientSide(page, bob);
|
||||
await joinServerClientSide(page, serverName);
|
||||
});
|
||||
|
||||
await test.step('Bob does not inherit Alice custom emoji library', async () => {
|
||||
await openComposerEmojiModal(page);
|
||||
// The modal is open (the file input is asserted inside the helper), so an
|
||||
// empty grid is a genuine assertion rather than a timing artifact.
|
||||
await expect(libraryEmoji).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createServer(page: Page, serverName: string): Promise<void> {
|
||||
const searchPage = new ServerSearchPage(page);
|
||||
|
||||
await expect(searchPage.createServerButton).toBeVisible({ timeout: 15_000 });
|
||||
await searchPage.createServerButton.click();
|
||||
|
||||
await expect(searchPage.serverNameInput).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Client-side nav can render the form before its `(ngModelChange)` handler is
|
||||
// wired, so an early fill never reaches the backing signal. Clear + refill
|
||||
// until the submit button actually enables.
|
||||
await expect.poll(async () => {
|
||||
await searchPage.serverNameInput.fill('');
|
||||
await searchPage.serverNameInput.fill(serverName);
|
||||
|
||||
return searchPage.createSubmitButton.isEnabled();
|
||||
}, { timeout: 15_000 }).toBe(true);
|
||||
|
||||
await searchPage.createSubmitButton.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await new ChatMessagesPage(page).waitForReady();
|
||||
}
|
||||
|
||||
async function joinServerClientSide(page: Page, serverName: string): Promise<void> {
|
||||
const searchPage = new ServerSearchPage(page);
|
||||
|
||||
await page.locator('a[href="/servers"]').first()
|
||||
.click();
|
||||
|
||||
await expect(searchPage.searchInput).toBeVisible({ timeout: 15_000 });
|
||||
await searchPage.searchInput.fill(serverName);
|
||||
|
||||
const serverCard = page.locator('div[title]', { hasText: serverName }).first();
|
||||
|
||||
await expect(serverCard).toBeVisible({ timeout: 20_000 });
|
||||
await serverCard.dblclick();
|
||||
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await new ChatMessagesPage(page).waitForReady();
|
||||
}
|
||||
|
||||
async function openComposerEmojiModal(page: Page): Promise<void> {
|
||||
const picker = page.locator('app-custom-emoji-picker');
|
||||
const fileInput = picker.locator('input[type="file"]');
|
||||
|
||||
// Reset to a known state: dismiss any open picker, then open it fresh.
|
||||
await page.keyboard.press('Escape').catch(() => {});
|
||||
await expect(picker).toHaveCount(0, { timeout: 5_000 })
|
||||
.catch(() => {});
|
||||
|
||||
await page.locator('app-chat-message-composer')
|
||||
.getByRole('button', { name: 'Open emoji selector' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(picker).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The compact picker exposes a button that opens the full panel (with the
|
||||
// upload field and the custom-emoji grid).
|
||||
await picker.getByRole('button', { name: 'Open emoji selector' }).click();
|
||||
await expect(fileInput).toBeAttached({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function registerClientSide(page: Page, user: TestUser): Promise<void> {
|
||||
const loginPage = new LoginPage(page);
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await expect(loginPage.registerLink).toBeVisible({ timeout: 15_000 });
|
||||
await loginPage.registerLink.click();
|
||||
await expect(registerPage.usernameInput).toBeVisible({ timeout: 15_000 });
|
||||
await submitRegistration(page, user);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the registration form resiliently. On client-side navigation the
|
||||
* template-driven `ngModel` can attach a tick after the input is visible, so an
|
||||
* early `fill` is overwritten back to empty. Re-fill until every value sticks.
|
||||
*/
|
||||
async function submitRegistration(page: Page, user: TestUser): Promise<void> {
|
||||
const username = page.locator('#register-username');
|
||||
const displayName = page.locator('#register-display-name');
|
||||
const password = page.locator('#register-password');
|
||||
|
||||
await expect.poll(async () => {
|
||||
await username.fill(user.username);
|
||||
await displayName.fill(user.displayName);
|
||||
await password.fill(user.password);
|
||||
|
||||
return [
|
||||
await username.inputValue(),
|
||||
await displayName.inputValue(),
|
||||
await password.inputValue()
|
||||
].join('|');
|
||||
}, { timeout: 15_000 }).toBe([
|
||||
user.username,
|
||||
user.displayName,
|
||||
user.password
|
||||
].join('|'));
|
||||
|
||||
await page.getByRole('button', { name: 'Create Account' }).click();
|
||||
}
|
||||
|
||||
async function logoutClientSide(page: Page): Promise<void> {
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
||||
|
||||
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(new LoginPage(page).usernameInput).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
|
||||
/**
|
||||
* Regression coverage for "Sending files between users doesn't really work":
|
||||
* a generic (non-media) file above the 10 MB auto-save cap sent to a browser
|
||||
* receiver. The receiver clicks Request; previously the chunk handler dropped
|
||||
* every incoming chunk with a silent file-too-large error, the sender's ack
|
||||
* wait timed out, and the GUI never changed.
|
||||
*/
|
||||
const LARGE_FILE_SIZE_BYTES = 11 * 1024 * 1024;
|
||||
|
||||
test.describe('Large generic file transfer', () => {
|
||||
test.describe.configure({ timeout: 420_000, retries: 1 });
|
||||
|
||||
test('browser receiver can request and download a generic file above the auto-save cap', async ({ createClient }) => {
|
||||
const suffix = uniqueName('largefile');
|
||||
const serverName = `Large File Server ${suffix}`;
|
||||
const fileName = `${suffix}-dataset.bin`;
|
||||
const caption = `Large file upload ${suffix}`;
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
const aliceMessages = new ChatMessagesPage(alice.page);
|
||||
const bobMessages = new ChatMessagesPage(bob.page);
|
||||
|
||||
await test.step('Alice and Bob register and meet in a server', async () => {
|
||||
const aliceRegister = new RegisterPage(alice.page);
|
||||
|
||||
await aliceRegister.goto();
|
||||
await aliceRegister.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||
await expect(alice.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const bobRegister = new RegisterPage(bob.page);
|
||||
|
||||
await bobRegister.goto();
|
||||
await bobRegister.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||
await expect(bob.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const aliceSearch = new ServerSearchPage(alice.page);
|
||||
|
||||
await aliceSearch.createServer(serverName, { description: 'Large generic file transfer coverage' });
|
||||
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 });
|
||||
|
||||
await aliceMessages.waitForReady();
|
||||
await bobMessages.waitForReady();
|
||||
});
|
||||
|
||||
await test.step('Alice sends an 11 MB generic file', async () => {
|
||||
await attachGeneratedBinaryFile(aliceMessages, fileName, LARGE_FILE_SIZE_BYTES);
|
||||
await aliceMessages.sendMessage(caption);
|
||||
await expect(aliceMessages.getMessageItemByText(caption)).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
const bobBubble = bobMessages.getMessageItemByText(caption);
|
||||
|
||||
await test.step('Bob sees the attachment card with a Request button', async () => {
|
||||
await expect(bobBubble).toBeVisible({ timeout: 30_000 });
|
||||
await expect(bobBubble.getByText(fileName, { exact: false })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(bobBubble.getByRole('button', { name: /request/i })).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Bob requests the file and it downloads to completion', async () => {
|
||||
await bobBubble.getByRole('button', { name: /request/i }).click();
|
||||
|
||||
// The transfer must visibly progress (Cancel replaces Request) instead of
|
||||
// silently stalling at 0 bytes like the original bug.
|
||||
await expect(bobBubble.getByRole('button', { name: /cancel/i })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(bobBubble.getByRole('button', { name: /download/i })).toBeVisible({ timeout: 300_000 });
|
||||
await expect(bobBubble.getByText(/too large/i)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the file inside the page so the multi-megabyte payload never crosses
|
||||
* the CDP protocol as a base64 string.
|
||||
*/
|
||||
async function attachGeneratedBinaryFile(
|
||||
messages: ChatMessagesPage,
|
||||
fileName: string,
|
||||
sizeBytes: number
|
||||
): Promise<void> {
|
||||
await messages.waitForReady();
|
||||
|
||||
await messages.composerInput.evaluate((element, { name, size }) => {
|
||||
const bytes = new Uint8Array(size);
|
||||
|
||||
for (let index = 0; index < size; index++) {
|
||||
bytes[index] = (index * 31 + 7) & 0xff;
|
||||
}
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
dataTransfer.items.add(new File([bytes], name, { type: 'application/octet-stream' }));
|
||||
element.dispatchEvent(new DragEvent('drop', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer
|
||||
}));
|
||||
}, { name: fileName, size: sizeBytes });
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
type Client
|
||||
} from '../../fixtures/multi-client';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
|
||||
|
||||
const UPLOADER_LOCAL_MISSING_TEXT = 'Your original upload could not be found on this device';
|
||||
|
||||
test.describe('Local attachment persistence', () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test('remembers sent image and file across a page reload with no peer connected', async ({ createClient }) => {
|
||||
const scenario = await createSingleClientChatScenario(createClient);
|
||||
const serverName = `Persist Server ${uniqueName('persist')}`;
|
||||
const imageName = `${uniqueName('diagram')}.svg`;
|
||||
const fileName = `${uniqueName('notes')}.txt`;
|
||||
const imageCaption = `Persisted image ${uniqueName('caption')}`;
|
||||
const fileCaption = `Persisted file ${uniqueName('caption')}`;
|
||||
const imageAttachment = createTextFilePayload(imageName, 'image/svg+xml', buildMockSvgMarkup(imageName));
|
||||
const fileAttachment = createTextFilePayload(fileName, 'text/plain', `Attachment body for ${fileName}`);
|
||||
|
||||
await test.step('Create a server and open its room', async () => {
|
||||
await createServerAndOpenRoom(scenario.search, scenario.client.page, serverName, 'Local attachment persistence server');
|
||||
});
|
||||
|
||||
await test.step('Send an image and a generic file attachment', async () => {
|
||||
await scenario.messages.attachFiles([imageAttachment]);
|
||||
await scenario.messages.sendMessage(imageCaption);
|
||||
await scenario.messages.expectMessageImageLoaded(imageName);
|
||||
|
||||
await scenario.messages.attachFiles([fileAttachment]);
|
||||
await scenario.messages.sendMessage(fileCaption);
|
||||
await expect(scenario.client.page.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Wait for both attachments to be persisted locally', async () => {
|
||||
await waitForPersistedAttachmentBytes(scenario.client.page, 2);
|
||||
await waitForPersistedAttachmentRecords(scenario.client.page, 2);
|
||||
});
|
||||
|
||||
await test.step('Reload the page to simulate an application restart', async () => {
|
||||
await scenario.client.page.reload();
|
||||
await expect(scenario.client.page).toHaveURL(/\/(room|dashboard)/, { timeout: 30_000 });
|
||||
await openSavedRoomByName(scenario.client.page, serverName);
|
||||
await expect(scenario.messages.getMessageItemByText(imageCaption)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('The image still renders from local storage with no peer', async () => {
|
||||
await scenario.messages.expectMessageImageLoaded(imageName);
|
||||
await expect(scenario.client.page.getByText(UPLOADER_LOCAL_MISSING_TEXT, { exact: false })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('The generic file is still remembered with no missing-upload error', async () => {
|
||||
await expect(scenario.messages.getMessageItemByText(fileCaption)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.client.page.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.client.page.getByText(UPLOADER_LOCAL_MISSING_TEXT, { exact: false })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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!'
|
||||
};
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(
|
||||
credentials.username,
|
||||
credentials.displayName,
|
||||
credentials.password
|
||||
);
|
||||
|
||||
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
return {
|
||||
client,
|
||||
messages: new ChatMessagesPage(client.page),
|
||||
room: new ChatRoomPage(client.page),
|
||||
search: new ServerSearchPage(client.page)
|
||||
};
|
||||
}
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
interface CountOptions {
|
||||
databaseRole: 'attachment-files' | 'app';
|
||||
storeName: string;
|
||||
requireSavedPath: boolean;
|
||||
}
|
||||
|
||||
/** Counts records in the first matching IndexedDB store, optionally requiring a savedPath. */
|
||||
async function countIndexedDbRecords(page: Page, options: CountOptions): Promise<number> {
|
||||
return page.evaluate(async (countOptions: CountOptions) => {
|
||||
if (typeof indexedDB.databases !== 'function') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const databases = await indexedDB.databases();
|
||||
const matchingNames = databases
|
||||
.map((entry) => entry.name ?? '')
|
||||
.filter((name) => (countOptions.databaseRole === 'attachment-files'
|
||||
? name.startsWith('metoyou-attachment-files')
|
||||
: name === 'metoyou' || name.startsWith('metoyou::')));
|
||||
const countInDatabase = (databaseName: string): Promise<number> => new Promise<number>((resolve) => {
|
||||
const request = indexedDB.open(databaseName);
|
||||
|
||||
request.onerror = () => resolve(0);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const database = request.result;
|
||||
|
||||
if (!database.objectStoreNames.contains(countOptions.storeName)) {
|
||||
database.close();
|
||||
resolve(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const getAll = database.transaction(countOptions.storeName, 'readonly')
|
||||
.objectStore(countOptions.storeName)
|
||||
.getAll();
|
||||
|
||||
getAll.onsuccess = () => {
|
||||
const records = (getAll.result as { savedPath?: string }[]) ?? [];
|
||||
const matching = countOptions.requireSavedPath
|
||||
? records.filter((record) => !!record.savedPath)
|
||||
: records;
|
||||
|
||||
resolve(matching.length);
|
||||
database.close();
|
||||
};
|
||||
|
||||
getAll.onerror = () => {
|
||||
resolve(0);
|
||||
database.close();
|
||||
};
|
||||
};
|
||||
});
|
||||
const counts = await Promise.all(matchingNames.map(countInDatabase));
|
||||
|
||||
return counts.reduce((total, count) => total + count, 0);
|
||||
}, options);
|
||||
}
|
||||
|
||||
/** Polls until at least `minCount` attachment byte records exist in the browser file store. */
|
||||
async function waitForPersistedAttachmentBytes(page: Page, minCount: number): Promise<void> {
|
||||
await expect.poll(
|
||||
async () => countIndexedDbRecords(page, { databaseRole: 'attachment-files', storeName: 'files', requireSavedPath: false }),
|
||||
{ timeout: 20_000, message: 'attachment bytes should persist to IndexedDB before reload' }
|
||||
).toBeGreaterThanOrEqual(minCount);
|
||||
}
|
||||
|
||||
/** Polls until at least `minCount` attachment metadata records with a savedPath exist in the app database. */
|
||||
async function waitForPersistedAttachmentRecords(page: Page, minCount: number): Promise<void> {
|
||||
await expect.poll(
|
||||
async () => countIndexedDbRecords(page, { databaseRole: 'app', storeName: 'attachments', requireSavedPath: true }),
|
||||
{ timeout: 20_000, message: 'attachment metadata with savedPath should persist before reload' }
|
||||
).toBeGreaterThanOrEqual(minCount);
|
||||
}
|
||||
|
||||
function createTextFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload {
|
||||
return {
|
||||
name,
|
||||
mimeType,
|
||||
base64: Buffer.from(content, 'utf8').toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockSvgMarkup(label: string): string {
|
||||
return [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
|
||||
'<rect width="160" height="120" rx="18" fill="#0f172a" />',
|
||||
'<circle cx="38" cy="36" r="18" fill="#38bdf8" />',
|
||||
'<rect x="66" y="28" width="64" height="16" rx="8" fill="#f8fafc" />',
|
||||
'<rect x="24" y="74" width="112" height="12" rx="6" fill="#22c55e" />',
|
||||
`<text x="24" y="104" fill="#e2e8f0" font-size="12" font-family="Arial, sans-serif">${label}</text>`,
|
||||
'</svg>'
|
||||
].join('');
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { test, expect } 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 {
|
||||
MULTI_DEVICE_PASSWORD,
|
||||
closeClient,
|
||||
expectCrossDeviceMessage,
|
||||
expectSyncedMessage,
|
||||
expectSyncedMessageWithResync,
|
||||
expectServerPeerVisible,
|
||||
loginSecondDeviceIntoServer,
|
||||
reopenClientInServer,
|
||||
uniqueMultiDeviceName
|
||||
} from '../../helpers/multi-device-session';
|
||||
|
||||
test.describe('Multi-client chat sync', () => {
|
||||
test.describe.configure({ timeout: 360_000, retries: 1 });
|
||||
|
||||
test('syncs messages between same-user devices and late-joining users after offline gaps', async ({ createClient }) => {
|
||||
const suffix = uniqueMultiDeviceName('multi-chat-sync');
|
||||
const hostCredentials = {
|
||||
username: `ludde_${suffix}`,
|
||||
displayName: 'Ludde',
|
||||
password: MULTI_DEVICE_PASSWORD
|
||||
};
|
||||
const guestCredentials = {
|
||||
username: `azaaxin_${suffix}`,
|
||||
displayName: 'Azaaxin',
|
||||
password: MULTI_DEVICE_PASSWORD
|
||||
};
|
||||
const serverName = `Multi Client Chat Sync ${suffix}`;
|
||||
const sharedBaselineMessage = `Shared baseline ${suffix}`;
|
||||
const soloHostMessage = `Solo host message ${suffix}`;
|
||||
const liveGuestProbeMessage = `Live guest probe ${suffix}`;
|
||||
const offlineGapMessage = `Offline gap message ${suffix}`;
|
||||
const client1 = await createClient();
|
||||
const client2 = await createClient();
|
||||
const client3 = await createClient();
|
||||
|
||||
await test.step('client 1: host registers and creates the shared server', async () => {
|
||||
const registerPage = new RegisterPage(client1.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(
|
||||
hostCredentials.username,
|
||||
hostCredentials.displayName,
|
||||
hostCredentials.password
|
||||
);
|
||||
|
||||
await expect(client1.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const search = new ServerSearchPage(client1.page);
|
||||
|
||||
await search.createServer(serverName, {
|
||||
description: 'Multi-client chat sync regression coverage'
|
||||
});
|
||||
|
||||
await expect(client1.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
const messages1 = new ChatMessagesPage(client1.page);
|
||||
|
||||
await messages1.waitForReady();
|
||||
|
||||
await test.step('client 2: second host device joins the same server', async () => {
|
||||
await loginSecondDeviceIntoServer(client2.page, hostCredentials, serverName);
|
||||
});
|
||||
|
||||
const messages2 = new ChatMessagesPage(client2.page);
|
||||
|
||||
await messages2.waitForReady();
|
||||
|
||||
await test.step('both host devices exchange chat while online together', async () => {
|
||||
await expectCrossDeviceMessage(messages1, messages2, sharedBaselineMessage);
|
||||
});
|
||||
|
||||
await test.step('close the second host browser (client 2)', async () => {
|
||||
await closeClient(client2);
|
||||
});
|
||||
|
||||
await test.step('client 1 sends chat while the second host device is offline', async () => {
|
||||
await client1.page.bringToFront();
|
||||
await messages1.sendMessage(soloHostMessage);
|
||||
await expect(messages1.getMessageItemByText(soloHostMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('guest account registers ahead of joining the server', async () => {
|
||||
const registerPage = new RegisterPage(client3.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(
|
||||
guestCredentials.username,
|
||||
guestCredentials.displayName,
|
||||
guestCredentials.password
|
||||
);
|
||||
|
||||
await expect(client3.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
let messages3 = new ChatMessagesPage(client3.page);
|
||||
|
||||
await test.step('client 3: guest joins and receives existing chat history', async () => {
|
||||
// Keep the host tab active so its websocket + peer negotiation stay alive.
|
||||
await client1.page.bringToFront();
|
||||
await messages1.waitForReady();
|
||||
|
||||
const search = new ServerSearchPage(client3.page);
|
||||
|
||||
await search.joinServerFromSearch(serverName);
|
||||
await expect(client3.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
messages3 = new ChatMessagesPage(client3.page);
|
||||
await messages3.waitForReady();
|
||||
|
||||
// Presence gate: both users must see each other in the members panel
|
||||
// before cross-user chat delivery can be expected.
|
||||
await client1.page.bringToFront();
|
||||
await expectServerPeerVisible(client1.page, guestCredentials.displayName);
|
||||
await client3.page.bringToFront();
|
||||
await expectServerPeerVisible(client3.page, hostCredentials.displayName);
|
||||
|
||||
// Live delivery first - proves host <-> guest transport is actually up.
|
||||
await expectCrossDeviceMessage(messages1, messages3, liveGuestProbeMessage);
|
||||
|
||||
// History only replicates over P2P inventory once the peer link exists.
|
||||
await client1.page.bringToFront();
|
||||
await expectSyncedMessageWithResync(client3.page, messages3, sharedBaselineMessage);
|
||||
await expectSyncedMessageWithResync(client3.page, messages3, soloHostMessage);
|
||||
});
|
||||
|
||||
await test.step('close the guest browser (client 3)', async () => {
|
||||
await closeClient(client3);
|
||||
});
|
||||
|
||||
await test.step('reopen client 2 and send a message while client 1 stays online', async () => {
|
||||
await client1.page.bringToFront();
|
||||
const reopened = await reopenClientInServer(createClient, hostCredentials, serverName);
|
||||
|
||||
// Same-user catch-up uses account_sync, not P2P between own devices.
|
||||
await expectSyncedMessageWithResync(
|
||||
reopened.client.page,
|
||||
reopened.messages,
|
||||
soloHostMessage
|
||||
);
|
||||
|
||||
await reopened.messages.sendMessage(offlineGapMessage);
|
||||
await expect(reopened.messages.getMessageItemByText(offlineGapMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('reopened guest client receives the offline-gap message from host device 2', async () => {
|
||||
await client1.page.bringToFront();
|
||||
await messages1.waitForReady();
|
||||
|
||||
const reopenedGuest = await reopenClientInServer(createClient, guestCredentials, serverName);
|
||||
|
||||
// Presence gate before relying on cross-user delivery again.
|
||||
await client1.page.bringToFront();
|
||||
await expectServerPeerVisible(client1.page, guestCredentials.displayName);
|
||||
await reopenedGuest.client.page.bringToFront();
|
||||
await expectServerPeerVisible(reopenedGuest.client.page, hostCredentials.displayName);
|
||||
|
||||
await expectCrossDeviceMessage(messages1, reopenedGuest.messages, `Guest wake ${suffix}`);
|
||||
|
||||
await expectSyncedMessageWithResync(
|
||||
reopenedGuest.client.page,
|
||||
reopenedGuest.messages,
|
||||
offlineGapMessage
|
||||
);
|
||||
});
|
||||
|
||||
await test.step('primary host device still receives the message from its second device', async () => {
|
||||
await expectSyncedMessage(messages1, offlineGapMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
|
||||
import {
|
||||
MULTI_DEVICE_PASSWORD,
|
||||
loginSecondDeviceIntoServer,
|
||||
uniqueMultiDeviceName
|
||||
} from '../../helpers/multi-device-session';
|
||||
|
||||
const SHARED_FROM_DEVICE_TEXT = 'Shared from your device';
|
||||
|
||||
test.describe('Multi-device attachment sharing', () => {
|
||||
test.describe.configure({ timeout: 300_000, retries: 1 });
|
||||
|
||||
test('only the uploading device claims "Shared from your device"; the second same-user device can request it', async ({
|
||||
createClient
|
||||
}) => {
|
||||
const suffix = uniqueMultiDeviceName('attach-share');
|
||||
const credentials = {
|
||||
username: `share_${suffix}`,
|
||||
displayName: 'Multi Device User',
|
||||
password: MULTI_DEVICE_PASSWORD
|
||||
};
|
||||
const serverName = `Attachment Sharing ${suffix}`;
|
||||
const fileName = `${suffix}-handoff.bin`;
|
||||
const caption = `Uploaded from device A ${suffix}`;
|
||||
const fileAttachment = createBinaryFilePayload(fileName, 'application/octet-stream', `binary-body-${suffix}`);
|
||||
const clientA = await createClient();
|
||||
const messagesA = new ChatMessagesPage(clientA.page);
|
||||
|
||||
await test.step('device A registers, creates a server, and uploads a generic file', async () => {
|
||||
const registerPage = new RegisterPage(clientA.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
|
||||
await expect(clientA.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const search = new ServerSearchPage(clientA.page);
|
||||
|
||||
await search.createServer(serverName, { description: 'Multi-device attachment sharing regression coverage' });
|
||||
await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
await messagesA.waitForReady();
|
||||
await messagesA.attachFiles([fileAttachment]);
|
||||
await messagesA.sendMessage(caption);
|
||||
await expect(messagesA.getMessageItemByText(caption)).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('device A (the uploader) shows "Shared from your device"', async () => {
|
||||
const bubbleA = messagesA.getMessageItemByText(caption);
|
||||
|
||||
await expect(bubbleA.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(bubbleA.getByText(SHARED_FROM_DEVICE_TEXT, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
const clientB = await createClient();
|
||||
const messagesB = new ChatMessagesPage(clientB.page);
|
||||
|
||||
await test.step('device B (same user) logs into the same server after the upload', async () => {
|
||||
await loginSecondDeviceIntoServer(clientB.page, credentials, serverName);
|
||||
// Keep device A active so it answers device B's account_sync_peer_online push.
|
||||
await clientA.page.bringToFront();
|
||||
await messagesA.waitForReady();
|
||||
await clientB.page.bringToFront();
|
||||
await messagesB.waitForReady();
|
||||
});
|
||||
|
||||
await test.step('device B receives the message and its attachment via same-user account sync', async () => {
|
||||
await expect(messagesB.getMessageItemByText(caption)).toBeVisible({ timeout: 90_000 });
|
||||
await expect(messagesB.getMessageItemByText(caption).getByText(fileName, { exact: false }))
|
||||
.toBeVisible({ timeout: 90_000 });
|
||||
});
|
||||
|
||||
await test.step('device B does NOT claim to share it and can request/download the file', async () => {
|
||||
const bubbleB = messagesB.getMessageItemByText(caption);
|
||||
|
||||
// The regression: device B used to render "Shared from your device" and hide the
|
||||
// download affordance because the synced metadata carried the uploader's user id.
|
||||
await expect(bubbleB.getByText(SHARED_FROM_DEVICE_TEXT, { exact: false })).toHaveCount(0);
|
||||
|
||||
// Device B must instead be able to fetch the file as any recipient would.
|
||||
const getButton = bubbleB.getByRole('button', { name: /request|download/i });
|
||||
|
||||
await expect(getButton.first()).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('relays file-announce metadata to a sibling device that is already online during upload', async ({
|
||||
createClient
|
||||
}) => {
|
||||
const suffix = uniqueMultiDeviceName('attach-online');
|
||||
const credentials = {
|
||||
username: `online_${suffix}`,
|
||||
displayName: 'Multi Device User',
|
||||
password: MULTI_DEVICE_PASSWORD
|
||||
};
|
||||
const serverName = `Attachment Online Relay ${suffix}`;
|
||||
const fileName = `${suffix}-relay.bin`;
|
||||
const caption = `Uploaded while device B was online ${suffix}`;
|
||||
const fileAttachment = createBinaryFilePayload(fileName, 'application/octet-stream', `relay-body-${suffix}`);
|
||||
const clientA = await createClient();
|
||||
const clientB = await createClient();
|
||||
const messagesA = new ChatMessagesPage(clientA.page);
|
||||
const messagesB = new ChatMessagesPage(clientB.page);
|
||||
|
||||
await test.step('device A registers and creates a server', async () => {
|
||||
const registerPage = new RegisterPage(clientA.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
|
||||
await expect(clientA.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const search = new ServerSearchPage(clientA.page);
|
||||
|
||||
await search.createServer(serverName, { description: 'Sibling online file-announce relay coverage' });
|
||||
await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
await messagesA.waitForReady();
|
||||
});
|
||||
|
||||
await test.step('device B logs into the same server before the upload starts', async () => {
|
||||
await loginSecondDeviceIntoServer(clientB.page, credentials, serverName);
|
||||
await clientA.page.bringToFront();
|
||||
await messagesA.waitForReady();
|
||||
await clientB.page.bringToFront();
|
||||
await messagesB.waitForReady();
|
||||
});
|
||||
|
||||
await test.step('device A uploads while device B is already in the room', async () => {
|
||||
await clientA.page.bringToFront();
|
||||
await messagesA.attachFiles([fileAttachment]);
|
||||
await messagesA.sendMessage(caption);
|
||||
await expect(messagesA.getMessageItemByText(caption)).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('device B learns attachment metadata without a server-rail click dance', async () => {
|
||||
await clientB.page.bringToFront();
|
||||
await expect(messagesB.getMessageItemByText(caption)).toBeVisible({ timeout: 90_000 });
|
||||
await expect(messagesB.getMessageItemByText(caption).getByText(fileName, { exact: false }))
|
||||
.toBeVisible({ timeout: 90_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createBinaryFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload {
|
||||
return {
|
||||
name,
|
||||
mimeType,
|
||||
base64: Buffer.from(content, 'utf8').toString('base64')
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
|
||||
|
||||
test.describe('Multi-image gallery grouping', () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test('groups three images in one message bubble with a visible grid', async ({ createClient }) => {
|
||||
const suffix = uniqueName('gallery');
|
||||
const client = await createClient();
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
const search = new ServerSearchPage(client.page);
|
||||
const messages = new ChatMessagesPage(client.page);
|
||||
const serverName = `Gallery Group ${suffix}`;
|
||||
const imageNames = [
|
||||
`${suffix}-one.svg`,
|
||||
`${suffix}-two.svg`,
|
||||
`${suffix}-three.svg`
|
||||
];
|
||||
const images = imageNames.map((name) => createSvgFilePayload(name));
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(`gallery_${suffix}`, 'Gallery User', 'TestPass123!');
|
||||
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
await search.createServer(serverName, { description: 'Multi-image gallery regression server' });
|
||||
await expect(client.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
await messages.waitForReady();
|
||||
|
||||
await messages.attachFiles(images);
|
||||
await messages.sendPendingAttachments();
|
||||
|
||||
for (const imageName of imageNames) {
|
||||
await messages.expectMessageImageLoaded(imageName);
|
||||
}
|
||||
|
||||
const messageId = await messages.getMessageIdContainingImage(imageNames[0]);
|
||||
|
||||
expect(messageId).toBeTruthy();
|
||||
|
||||
const bubble = client.page.locator(`[data-message-id="${messageId}"]`);
|
||||
|
||||
await expect(bubble.locator('img[alt$=".svg"]')).toHaveCount(3, { timeout: 20_000 });
|
||||
await expect(bubble.locator('.chat-image-grid')).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10_000)}`;
|
||||
}
|
||||
|
||||
function createSvgFilePayload(name: string): ChatDropFilePayload {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" fill="#4A217A"/></svg>';
|
||||
|
||||
return {
|
||||
name,
|
||||
mimeType: 'image/svg+xml',
|
||||
base64: Buffer.from(svg, 'utf8').toString('base64')
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
expect,
|
||||
type BrowserContext,
|
||||
type Locator,
|
||||
type Page
|
||||
} from '@playwright/test';
|
||||
@@ -35,6 +36,7 @@ test.describe('Chat notifications', () => {
|
||||
await clearDesktopNotifications(scenario.alice.page);
|
||||
await scenario.bobRoom.joinTextChannel(scenario.channelName);
|
||||
await scenario.bobMessages.sendMessage(message);
|
||||
await expectUnreadCounts(scenario.alice.page, scenario.serverName, scenario.channelName);
|
||||
});
|
||||
|
||||
await test.step('Alice receives a desktop notification with the channel preview', async () => {
|
||||
@@ -67,8 +69,7 @@ test.describe('Chat notifications', () => {
|
||||
});
|
||||
|
||||
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 expectUnreadCounts(scenario.alice.page, scenario.serverName, scenario.channelName);
|
||||
});
|
||||
|
||||
await test.step('Alice does not get a muted desktop popup', async () => {
|
||||
@@ -96,7 +97,7 @@ async function createNotificationScenario(createClient: () => Promise<Client>):
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
|
||||
await installDesktopNotificationSpy(alice.page);
|
||||
await installDesktopNotificationSpy(alice.context);
|
||||
|
||||
await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password);
|
||||
await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password);
|
||||
@@ -143,8 +144,8 @@ async function registerUser(page: Page, username: string, displayName: string, p
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function installDesktopNotificationSpy(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
async function installDesktopNotificationSpy(context: BrowserContext): Promise<void> {
|
||||
await context.addInitScript(() => {
|
||||
const notifications: DesktopNotificationRecord[] = [];
|
||||
|
||||
class MockNotification {
|
||||
@@ -250,6 +251,11 @@ function getUnreadBadge(container: Locator): Locator {
|
||||
return container.locator('span.rounded-full').first();
|
||||
}
|
||||
|
||||
async function expectUnreadCounts(page: Page, serverName: string, channelName: string): Promise<void> {
|
||||
await expect(getUnreadBadge(getSavedRoomButton(page, serverName))).toHaveText('1', { timeout: 45_000 });
|
||||
await expect(getUnreadBadge(getTextChannelButton(page, channelName))).toHaveText('1', { timeout: 45_000 });
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
|
||||
@@ -367,11 +367,10 @@ async function launchPersistentSession(
|
||||
});
|
||||
|
||||
await installTestServerEndpoint(context, testServerPort);
|
||||
await installWebRTCTracking(context);
|
||||
|
||||
const page = context.pages()[0] ?? await context.newPage();
|
||||
|
||||
await installWebRTCTracking(page);
|
||||
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
|
||||
@@ -196,11 +196,10 @@ async function launchPersistentSession(userDataDir: string, testServerPort: numb
|
||||
});
|
||||
|
||||
await installTestServerEndpoint(context, testServerPort);
|
||||
await installWebRTCTracking(context);
|
||||
|
||||
const page = context.pages()[0] ?? (await context.newPage());
|
||||
|
||||
await installWebRTCTracking(page);
|
||||
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
import {
|
||||
ADAPTIVE_FOREGROUND_ICON_RATIO,
|
||||
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
||||
findMissingLauncherResources,
|
||||
findStockCapacitorResources,
|
||||
isBrandLauncherBackgroundColor,
|
||||
readAdaptiveIconBackgroundColor,
|
||||
REQUIRED_LAUNCHER_ICON_FILES,
|
||||
REQUIRED_SPLASH_FILES,
|
||||
SPLASH_ICON_RATIO
|
||||
} from '../../../toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules';
|
||||
|
||||
/**
|
||||
* Regression coverage for: "No android app icon" - the Capacitor shell shipped
|
||||
* the stock Ionic placeholder launcher icon instead of the Toju brand mark.
|
||||
*
|
||||
* A native launcher icon cannot be asserted through a running browser, so this
|
||||
* spec verifies the committed Android resources directly: every density is
|
||||
* present, none still match a stock Capacitor placeholder, the adaptive-icon
|
||||
* background is the brand purple, and the generated bitmaps actually contain the
|
||||
* brand mark (white cat on a purple disc). This is deterministic - no emulator,
|
||||
* no timing - so it stays reliable in CI.
|
||||
*/
|
||||
|
||||
const REPO_ROOT = join(__dirname, '..', '..', '..');
|
||||
const RES_DIR = join(REPO_ROOT, 'toju-app', 'android', 'app', 'src', 'main', 'res');
|
||||
const BRAND_PURPLE = { r: 0x4a, g: 0x21, b: 0x7a };
|
||||
const WHITE = { r: 255, g: 255, b: 255 };
|
||||
const COLOR_TOLERANCE = 24;
|
||||
|
||||
function sha256(resRelativePath: string): string {
|
||||
return createHash('sha256').update(readFileSync(join(RES_DIR, resRelativePath)))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function colorDistance(
|
||||
left: { r: number; g: number; b: number },
|
||||
right: { r: number; g: number; b: number }
|
||||
): number {
|
||||
return Math.max(Math.abs(left.r - right.r), Math.abs(left.g - right.g), Math.abs(left.b - right.b));
|
||||
}
|
||||
|
||||
async function samplePixel(
|
||||
resRelativePath: string,
|
||||
xRatio: number,
|
||||
yRatio: number
|
||||
): Promise<{ r: number; g: number; b: number }> {
|
||||
const { data, info } = await sharp(join(RES_DIR, resRelativePath)).raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const x = Math.min(info.width - 1, Math.floor(info.width * xRatio));
|
||||
const y = Math.min(info.height - 1, Math.floor(info.height * yRatio));
|
||||
const offset = (y * info.width + x) * info.channels;
|
||||
|
||||
return { r: data[offset], g: data[offset + 1], b: data[offset + 2] };
|
||||
}
|
||||
|
||||
test.describe('Android brand app icon', () => {
|
||||
test('ships a launcher icon and splash for every required density', () => {
|
||||
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
|
||||
const present = allRequired.filter((file) => existsSync(join(RES_DIR, file)));
|
||||
|
||||
expect(findMissingLauncherResources(present)).toEqual([]);
|
||||
});
|
||||
|
||||
test('replaces every stock Capacitor placeholder with the brand asset', () => {
|
||||
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
|
||||
const hashByFile = Object.fromEntries(
|
||||
allRequired.filter((file) => existsSync(join(RES_DIR, file))).map((file) => [file, sha256(file)])
|
||||
);
|
||||
|
||||
expect(findStockCapacitorResources(hashByFile)).toEqual([]);
|
||||
});
|
||||
|
||||
test('uses the brand purple as the adaptive-icon background', () => {
|
||||
const valuesXml = readFileSync(join(RES_DIR, 'values', 'ic_launcher_background.xml'), 'utf8');
|
||||
const color = readAdaptiveIconBackgroundColor(valuesXml);
|
||||
|
||||
expect(color).not.toBe('#FFFFFF');
|
||||
expect(isBrandLauncherBackgroundColor(color)).toBe(true);
|
||||
expect(color?.toLowerCase()).toBe(BRAND_LAUNCHER_BACKGROUND_COLOR.toLowerCase());
|
||||
});
|
||||
|
||||
test('renders the brand mark (white cat on a purple disc) in the launcher bitmap', async () => {
|
||||
const launcher = 'mipmap-xxxhdpi/ic_launcher.png';
|
||||
const ringTop = await samplePixel(launcher, 0.5, 0.12);
|
||||
const ringLeft = await samplePixel(launcher, 0.12, 0.5);
|
||||
const faceCenter = await samplePixel(launcher, 0.5, 0.5);
|
||||
|
||||
expect(colorDistance(ringTop, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||
expect(colorDistance(ringLeft, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||
expect(colorDistance(faceCenter, WHITE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||
});
|
||||
|
||||
test('renders the splash art as the brand mark centred on a purple field', async () => {
|
||||
const splash = 'drawable-port-xhdpi/splash.png';
|
||||
const corner = await samplePixel(splash, 0.04, 0.04);
|
||||
const center = await samplePixel(splash, 0.5, 0.5);
|
||||
|
||||
expect(colorDistance(corner, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||
expect(colorDistance(center, WHITE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||
});
|
||||
|
||||
test('insets the adaptive foreground so launcher masks do not clip the cat face', async () => {
|
||||
const foreground = 'mipmap-xxxhdpi/ic_launcher_foreground.png';
|
||||
const { data, info } = await sharp(join(RES_DIR, foreground)).ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const topCenterOffset = (0 * info.width + Math.floor(info.width / 2)) * info.channels;
|
||||
|
||||
expect(data[topCenterOffset + 3]).toBeLessThan(32);
|
||||
expect(ADAPTIVE_FOREGROUND_ICON_RATIO).toBeCloseTo(66 / 108, 5);
|
||||
expect(SPLASH_ICON_RATIO).toBeLessThan(0.4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
|
||||
/**
|
||||
* Regression coverage for: "No login screen mobile phone on startup".
|
||||
*
|
||||
* Signed-out mobile users used to be left on a logged-out /dashboard (the
|
||||
* startup redirect special-cased mobile + root/dashboard and kept them there),
|
||||
* so they were never greeted with the login screen. The fix removes that mobile
|
||||
* exception: signed-out visitors are sent to /login on every platform.
|
||||
*
|
||||
* The mobile viewport must be set BEFORE navigation so ViewportService reports
|
||||
* `isMobile === true` at app bootstrap, which is exactly when the redirect ran.
|
||||
*/
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
test.describe('Mobile login screen on startup', () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test('greets a signed-out mobile visitor on /dashboard with the login screen', async ({ createClient }) => {
|
||||
const { page } = await createClient();
|
||||
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expect(page.locator('#login-username')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('greets a signed-out mobile visitor on the app root with the login screen', async ({ createClient }) => {
|
||||
const { page } = await createClient();
|
||||
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expect(page.locator('#login-username')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from '../../fixtures/multi-client';
|
||||
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||
import { MOBILE_VIEWPORT, openSettingsModal } from '../../helpers/settings-modal';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
|
||||
test.describe('Mobile settings logout', () => {
|
||||
test('exposes logout in the settings menu on mobile viewports', async ({ createClient }) => {
|
||||
const { page } = await createClient();
|
||||
const suffix = `mobile_logout_${Date.now()}`;
|
||||
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
|
||||
const register = new RegisterPage(page);
|
||||
|
||||
await register.goto();
|
||||
await register.register(`user_${suffix}`, 'Mobile Logout User', 'TestPass123!');
|
||||
await expectDashboardReady(page);
|
||||
|
||||
await openSettingsModal(page);
|
||||
await page.getByTestId('settings-logout-button').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expect(page.locator('#login-username')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,20 @@ import {
|
||||
test,
|
||||
type Client
|
||||
} from '../../fixtures/multi-client';
|
||||
import { openPluginStore } from '../../helpers/app-menu';
|
||||
import {
|
||||
addPluginSource,
|
||||
E2E_PLUGIN_SOURCE_URL,
|
||||
E2E_PLUGIN_TITLE
|
||||
} from '../../helpers/plugin-store';
|
||||
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
||||
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 PLUGIN_SOURCE_URL = E2E_PLUGIN_SOURCE_URL;
|
||||
const PLUGIN_TITLE = E2E_PLUGIN_TITLE;
|
||||
const EDITED_MESSAGE = 'Plugin API edited message';
|
||||
const ORIGINAL_MESSAGE = 'Plugin API original message';
|
||||
const DELETED_MESSAGE = 'Plugin API deleted message';
|
||||
@@ -35,8 +42,7 @@ test.describe('Plugin API multi-user runtime', () => {
|
||||
});
|
||||
|
||||
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 installRequiredServerPluginsViaModal(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 });
|
||||
});
|
||||
@@ -87,6 +93,9 @@ async function createPluginApiScenario(createClient: () => Promise<Client>): Pro
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
|
||||
await installWebRTCTracking(alice.page);
|
||||
await installWebRTCTracking(bob.page);
|
||||
|
||||
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
|
||||
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
|
||||
|
||||
@@ -98,13 +107,10 @@ async function createPluginApiScenario(createClient: () => Promise<Client>): Pro
|
||||
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 bobSearch.joinServerFromSearch(serverName);
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
||||
|
||||
const bobRoom = new ChatRoomPage(bob.page);
|
||||
@@ -113,6 +119,9 @@ async function createPluginApiScenario(createClient: () => Promise<Client>): Pro
|
||||
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(aliceRoom.voiceControls).toBeVisible({ timeout: 30_000 });
|
||||
await expect(bobRoom.voiceControls).toBeVisible({ timeout: 30_000 });
|
||||
await installGrantAndActivatePlugin(alice.page, true);
|
||||
await closeSettingsModal(alice.page);
|
||||
await expect(soundboardComposerButton(alice.page)).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const aliceMessages = new ChatMessagesPage(alice.page);
|
||||
const bobMessages = new ChatMessagesPage(bob.page);
|
||||
@@ -141,14 +150,11 @@ async function registerUser(page: Page, username: string, displayName: string):
|
||||
}
|
||||
|
||||
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 openPluginStore(page);
|
||||
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 addPluginSource(page, PLUGIN_SOURCE_URL);
|
||||
await page.locator('article', { hasText: PLUGIN_TITLE }).getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ })
|
||||
.click();
|
||||
|
||||
@@ -171,6 +177,14 @@ async function installGrantAndActivatePlugin(page: Page, installFromStore: boole
|
||||
await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function installRequiredServerPluginsViaModal(page: Page): Promise<void> {
|
||||
const installButton = page.getByRole('button', { name: 'Install plugins' });
|
||||
|
||||
await expect(installButton).toBeVisible({ timeout: 30_000 });
|
||||
await installButton.click();
|
||||
await expect(installButton).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function closeSettingsModal(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('plugin-manager')).toHaveCount(0);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { expect, test } from '../../fixtures/multi-client';
|
||||
import { openPluginStore } from '../../helpers/app-menu';
|
||||
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||
import { addPluginSource } from '../../helpers/plugin-store';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
|
||||
@@ -15,7 +18,7 @@ test.describe('Plugin manager UI', () => {
|
||||
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 people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
||||
await expectDashboardReady(page);
|
||||
await search.createServer(`Plugin API Server ${suffix}`, {
|
||||
description: 'Plugin manager UI E2E coverage'
|
||||
});
|
||||
@@ -23,16 +26,13 @@ test.describe('Plugin manager UI', () => {
|
||||
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 test.step('Open Plugin Store from the title-bar menu', async () => {
|
||||
await openPluginStore(page);
|
||||
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
await test.step('Install fixture plugin from source manifest', async () => {
|
||||
await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json');
|
||||
await page.getByRole('button', { name: 'Add Source' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 });
|
||||
await addPluginSource(page);
|
||||
const pluginCard = page.locator('article', { hasText: 'E2E All API Plugin' });
|
||||
|
||||
await pluginCard.getByRole('button', { name: 'Readme' }).click();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { type Client } from '../../fixtures/multi-client';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { dashboardSearchInput, expectDashboardReady } from '../../helpers/dashboard';
|
||||
import { MULTI_DEVICE_PASSWORD, uniqueMultiDeviceName } from '../../helpers/multi-device-session';
|
||||
|
||||
/**
|
||||
* Regression coverage for: "Fresh users have the server list in dashboard
|
||||
* completely empty until anything searched."
|
||||
*
|
||||
* The directory exposes a curated discovery view (featured/trending) that must
|
||||
* populate the dashboard "Popular Servers" panel and the /servers page without
|
||||
* the user typing a search query. A stale client-side host blocklist used to
|
||||
* short-circuit discovery to [] for the default production endpoints, so servers
|
||||
* only appeared once a search ran. These tests prove the default view is
|
||||
* populated, and that discovery self-heals when an endpoint lacks the
|
||||
* featured/trending routes (older signal servers answer them with 404).
|
||||
*/
|
||||
async function createPublicServer(client: Client, username: string, serverName: string): Promise<void> {
|
||||
const register = new RegisterPage(client.page);
|
||||
|
||||
await register.goto();
|
||||
await register.register(username, 'Discovery Host', MULTI_DEVICE_PASSWORD);
|
||||
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const search = new ServerSearchPage(client.page);
|
||||
|
||||
await search.createServer(serverName, { description: 'Public discovery server' });
|
||||
await expect(client.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
function popularServersPanel(client: Client) {
|
||||
return client.page.locator('div.rounded-xl', { hasText: 'Popular Servers' });
|
||||
}
|
||||
|
||||
test.describe('Server discovery default view', () => {
|
||||
test.describe.configure({ timeout: 120_000, retries: 1 });
|
||||
|
||||
test('a fresh account sees public servers in Popular Servers without searching', async ({ createClient }) => {
|
||||
const suffix = uniqueMultiDeviceName('discovery-default');
|
||||
const serverName = `Discovery Default ${suffix}`;
|
||||
const host = await createClient();
|
||||
const visitor = await createClient();
|
||||
|
||||
await test.step('host registers and publishes a public server', async () => {
|
||||
await createPublicServer(host, `host_${suffix}`, serverName);
|
||||
});
|
||||
|
||||
await test.step('a brand-new account registers', async () => {
|
||||
const register = new RegisterPage(visitor.page);
|
||||
|
||||
await register.goto();
|
||||
await register.register(`visitor_${suffix}`, 'Discovery Visitor', MULTI_DEVICE_PASSWORD);
|
||||
await expectDashboardReady(visitor.page);
|
||||
});
|
||||
|
||||
await test.step('Popular Servers lists the public server with no search query entered', async () => {
|
||||
await expect(dashboardSearchInput(visitor.page)).toHaveValue('');
|
||||
await expect(popularServersPanel(visitor).getByText(serverName)).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('discovery falls back to the public listing when featured/trending routes 404', async ({ createClient }) => {
|
||||
const suffix = uniqueMultiDeviceName('discovery-fallback');
|
||||
const serverName = `Discovery Fallback ${suffix}`;
|
||||
const host = await createClient();
|
||||
const visitor = await createClient();
|
||||
|
||||
await test.step('host registers and publishes a public server', async () => {
|
||||
await createPublicServer(host, `host_${suffix}`, serverName);
|
||||
});
|
||||
|
||||
await test.step('simulate a legacy signal server without featured/trending routes', async () => {
|
||||
const notFound = {
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' })
|
||||
};
|
||||
|
||||
await visitor.page.route('**/api/servers/featured**', (route) => route.fulfill(notFound));
|
||||
await visitor.page.route('**/api/servers/trending**', (route) => route.fulfill(notFound));
|
||||
});
|
||||
|
||||
await test.step('a brand-new account registers against the legacy-style endpoint', async () => {
|
||||
const register = new RegisterPage(visitor.page);
|
||||
|
||||
await register.goto();
|
||||
await register.register(`visitor_${suffix}`, 'Discovery Visitor', MULTI_DEVICE_PASSWORD);
|
||||
await expectDashboardReady(visitor.page);
|
||||
});
|
||||
|
||||
await test.step('Popular Servers still lists the server via the public-listing fallback', async () => {
|
||||
await expect(dashboardSearchInput(visitor.page)).toHaveValue('');
|
||||
await expect(popularServersPanel(visitor).getByText(serverName)).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
@@ -88,7 +89,7 @@ test.describe('Connectivity warning', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||
await expect(alice.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
||||
await expectDashboardReady(alice.page);
|
||||
});
|
||||
|
||||
await test.step('Register Bob', async () => {
|
||||
@@ -96,7 +97,7 @@ test.describe('Connectivity warning', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||
await expect(bob.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
||||
await expectDashboardReady(bob.page);
|
||||
});
|
||||
|
||||
await test.step('Register Charlie', async () => {
|
||||
@@ -104,7 +105,7 @@ test.describe('Connectivity warning', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
|
||||
await expect(charlie.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
||||
await expectDashboardReady(charlie.page);
|
||||
});
|
||||
|
||||
// ── Create server and have everyone join ──
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { openSettingsFromMenu } from '../../helpers/app-menu';
|
||||
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
|
||||
test.describe('ICE server settings', () => {
|
||||
@@ -9,8 +11,8 @@ test.describe('ICE server settings', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
|
||||
await expect(page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTitle('Settings').click();
|
||||
await expectDashboardReady(page);
|
||||
await openSettingsFromMenu(page);
|
||||
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 });
|
||||
@@ -101,7 +103,7 @@ test.describe('ICE server settings', () => {
|
||||
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 openSettingsFromMenu(page);
|
||||
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 });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
@@ -89,7 +90,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||
await expect(alice.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
||||
await expectDashboardReady(alice.page);
|
||||
});
|
||||
|
||||
await test.step('Register Bob', async () => {
|
||||
@@ -97,7 +98,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||
await expect(bob.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
||||
await expectDashboardReady(bob.page);
|
||||
});
|
||||
|
||||
await test.step('Alice creates a server', async () => {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test } from '../../fixtures/multi-client';
|
||||
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
|
||||
import { startTestServer } from '../../helpers/test-server';
|
||||
import { readSignalServerCredentialFromPage } from '../../helpers/auth-api';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
|
||||
/**
|
||||
* Regression coverage for "User receiving direct call doesn't get notified":
|
||||
* starting a call from the DM chat header (steps: open DM of a user, click
|
||||
* call) must ring the recipient - incoming-call modal, ring audio, and a
|
||||
* server-rail call entry. Includes the cross-signal topology where the callee
|
||||
* is addressed by a provisioned actor id instead of their home identity.
|
||||
*/
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const PRIMARY_SIGNAL_ID = 'e2e-dm-ring-primary';
|
||||
const SECONDARY_SIGNAL_ID = 'e2e-dm-ring-secondary';
|
||||
|
||||
test.describe('DM header call ring', () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test('callee is notified when the caller starts the call from the DM chat header', async ({ createClient }) => {
|
||||
const suffix = uniqueName('dm-ring');
|
||||
const serverName = `DM Ring Server ${suffix}`;
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
|
||||
await installRingInstrumentation(bob.page);
|
||||
|
||||
await test.step('Alice and Bob register and meet in a server', async () => {
|
||||
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: 'DM header call ring regression coverage' });
|
||||
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();
|
||||
});
|
||||
|
||||
await test.step('Both users open the DM view; live DM delivery confirms the transport works', async () => {
|
||||
const bobUserCard = 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(alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||
|
||||
const aliceUserCard = bob.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Alice' }).first();
|
||||
|
||||
await expect(aliceUserCard).toBeVisible({ timeout: 20_000 });
|
||||
await aliceUserCard.getByRole('button', { name: 'Message Alice' }).click();
|
||||
await expect(bob.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||
|
||||
// Mirrors the bug report: the users are in the DM view (not a server
|
||||
// room) when the call starts. The message must arrive live so a broken
|
||||
// ring cannot be blamed on a dead transport.
|
||||
await alice.page.getByTestId('dm-input').fill(`hello before call ${suffix}`);
|
||||
await alice.page.getByTestId('dm-input').press('Enter');
|
||||
await expect(bob.page.locator('app-dm-chat').getByText(`hello before call ${suffix}`)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Alice starts the call from the DM chat header', async () => {
|
||||
const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' });
|
||||
|
||||
await expect(callButton).toBeVisible({ timeout: 20_000 });
|
||||
await expect(callButton).toBeEnabled({ timeout: 20_000 });
|
||||
await callButton.click();
|
||||
await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Bob gets the incoming-call modal, ring audio, and rail entry', async () => {
|
||||
await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
|
||||
|
||||
await expect
|
||||
.poll(async () => await getCallAudioPlayCount(bob.page), {
|
||||
timeout: 20_000,
|
||||
intervals: [500, 1_000]
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('callee homed on another signal server is notified when called via their provisioned actor id', async ({ createClient, testServer }) => {
|
||||
const secondaryServer = await startTestServer();
|
||||
|
||||
try {
|
||||
const suffix = uniqueName('xsig-ring');
|
||||
const serverName = `Cross Signal Ring ${suffix}`;
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
const endpoints = [
|
||||
{
|
||||
id: PRIMARY_SIGNAL_ID,
|
||||
name: 'E2E Ring Signal A',
|
||||
url: testServer.url,
|
||||
isActive: true,
|
||||
status: 'online'
|
||||
},
|
||||
{
|
||||
id: SECONDARY_SIGNAL_ID,
|
||||
name: 'E2E Ring Signal B',
|
||||
url: secondaryServer.url,
|
||||
isActive: true,
|
||||
status: 'online'
|
||||
}
|
||||
];
|
||||
|
||||
await installTestServerEndpoints(alice.context, endpoints);
|
||||
await installTestServerEndpoints(bob.context, endpoints);
|
||||
await installRingInstrumentation(bob.page);
|
||||
|
||||
await test.step('Alice registers on the primary signal, Bob on the secondary', async () => {
|
||||
const aliceRegister = new RegisterPage(alice.page);
|
||||
|
||||
await aliceRegister.goto();
|
||||
await aliceRegister.serverSelect.selectOption(PRIMARY_SIGNAL_ID);
|
||||
await aliceRegister.register(`alice_${suffix}`, 'Alice', USER_PASSWORD);
|
||||
await expect(alice.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||
|
||||
const bobRegister = new RegisterPage(bob.page);
|
||||
|
||||
await bobRegister.goto();
|
||||
await bobRegister.serverSelect.selectOption(SECONDARY_SIGNAL_ID);
|
||||
await bobRegister.register(`bob_${suffix}`, 'Bob', USER_PASSWORD);
|
||||
await expect(bob.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('They meet in a room on the primary signal; Bob gets a provisioned actor identity', async () => {
|
||||
const aliceSearch = new ServerSearchPage(alice.page);
|
||||
|
||||
await aliceSearch.createServer(serverName, {
|
||||
description: 'Cross-signal DM call ring coverage',
|
||||
sourceId: PRIMARY_SIGNAL_ID
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
await expect.poll(async () =>
|
||||
await readSignalServerCredentialFromPage(bob.page, testServer.url),
|
||||
{ timeout: 30_000 }
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
await test.step('Alice opens the DM with Bob and calls from the DM chat header', async () => {
|
||||
const bobUserCard = 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(alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||
|
||||
const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' });
|
||||
|
||||
await expect(callButton).toBeVisible({ timeout: 20_000 });
|
||||
await expect(callButton).toBeEnabled({ timeout: 20_000 });
|
||||
await callButton.click();
|
||||
await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Bob gets the incoming-call modal and ring audio', async () => {
|
||||
await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect
|
||||
.poll(async () => await getCallAudioPlayCount(bob.page), {
|
||||
timeout: 20_000,
|
||||
intervals: [500, 1_000]
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
} finally {
|
||||
await secondaryServer.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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(/\/dashboard/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function installRingInstrumentation(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const OriginalAudio = window.Audio;
|
||||
const callAudioState = { playCount: 0 };
|
||||
|
||||
(window as Window & { __callAudioState?: typeof callAudioState }).__callAudioState = callAudioState;
|
||||
|
||||
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);
|
||||
|
||||
audio.play = () => {
|
||||
if (isCallAudio(audio)) {
|
||||
callAudioState.playCount += 1;
|
||||
}
|
||||
|
||||
return originalPlay();
|
||||
};
|
||||
|
||||
return audio;
|
||||
} as typeof Audio;
|
||||
|
||||
window.Audio.prototype = OriginalAudio.prototype;
|
||||
Object.setPrototypeOf(window.Audio, OriginalAudio);
|
||||
});
|
||||
}
|
||||
|
||||
async function getCallAudioPlayCount(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => (window as Window & { __callAudioState?: { playCount: number } }).__callAudioState?.playCount ?? 0);
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
@@ -10,19 +10,22 @@ import {
|
||||
dumpRtcDiagnostics,
|
||||
getConnectedPeerCount,
|
||||
installWebRTCTracking,
|
||||
installAutoResumeAudioContext,
|
||||
waitForAllPeerAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForPeerConnected
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import {
|
||||
authHeaders,
|
||||
readAuthTokenFromPage,
|
||||
registerTestUser
|
||||
registerTestUser,
|
||||
type AuthSession
|
||||
} from '../../helpers/auth-api';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||
import { getMinimumConnectedPeerMeshCount, waitForConnectedRemotePeerMesh } from '../../helpers/signal-manager';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
|
||||
// ── Signal endpoint identifiers ──────────────────────────────────────
|
||||
@@ -131,7 +134,8 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
await installTestServerEndpoints(client.context, groupEndpoints);
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installWebRTCTracking(client.context);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
clients.push({ ...client, user });
|
||||
}
|
||||
@@ -150,6 +154,14 @@ test.describe('Mixed signal-config voice', () => {
|
||||
}
|
||||
});
|
||||
|
||||
let secondaryRoomId = '';
|
||||
// Identity that owns the secondary room. The invite must be created with
|
||||
// this same API session: client 0 also auto-provisions a *separate*
|
||||
// identity on the secondary signal endpoint, which overwrites the page's
|
||||
// stored token, so reading the token back from the page would yield a
|
||||
// non-owner identity and the invite request would be rejected (NOT_MEMBER).
|
||||
let secondaryRoomOwner: AuthSession;
|
||||
|
||||
// ── 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
|
||||
@@ -190,6 +202,7 @@ test.describe('Mixed signal-config voice', () => {
|
||||
);
|
||||
|
||||
secondaryRoomId = secondaryRoom.id;
|
||||
secondaryRoomOwner = secondarySession;
|
||||
});
|
||||
|
||||
// ── Create invite links ─────────────────────────────────────
|
||||
@@ -198,7 +211,6 @@ test.describe('Mixed signal-config voice', () => {
|
||||
// Group D (secondary-only) needs invite to primary room.
|
||||
let primaryRoomInviteUrl: string;
|
||||
let secondaryRoomInviteUrl: string;
|
||||
let secondaryRoomId = '';
|
||||
|
||||
await test.step('Create invite links for cross-signal rooms', async () => {
|
||||
// Navigate to voice room to get its ID
|
||||
@@ -220,17 +232,14 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
|
||||
|
||||
// Create invite for secondary room (chat) via API
|
||||
const secondaryToken = await readAuthTokenFromPage(clients[0].page, secondaryServer.url);
|
||||
|
||||
if (!secondaryToken) {
|
||||
throw new Error('Missing session token for secondary signal invite creation');
|
||||
}
|
||||
|
||||
// Create invite for secondary room (chat) via API using the API session
|
||||
// that owns the room. The page-stored token for the secondary endpoint
|
||||
// belongs to client 0's auto-provisioned identity, which is not the
|
||||
// room owner and would be rejected with NOT_MEMBER.
|
||||
const secondaryInvite = await createInviteViaApi(
|
||||
secondaryServer.url,
|
||||
secondaryRoomId,
|
||||
secondaryToken,
|
||||
secondaryRoomOwner.token,
|
||||
clients[0].user.displayName
|
||||
);
|
||||
|
||||
@@ -294,8 +303,11 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
for (const client of clients) {
|
||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||
await client.page.waitForTimeout(2_000);
|
||||
}
|
||||
|
||||
await clients[0].page.waitForTimeout(10_000);
|
||||
|
||||
for (const client of clients) {
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
}
|
||||
@@ -304,11 +316,11 @@ test.describe('Mixed signal-config voice', () => {
|
||||
// ── 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)
|
||||
waitForPeerConnected(client.page, 90_000)
|
||||
));
|
||||
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
|
||||
));
|
||||
|
||||
await Promise.all(clients.map((client) =>
|
||||
@@ -318,7 +330,7 @@ test.describe('Mixed signal-config voice', () => {
|
||||
await clients[0].page.waitForTimeout(5_000);
|
||||
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
|
||||
));
|
||||
});
|
||||
|
||||
@@ -329,7 +341,6 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -366,18 +377,28 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const client of stayers) {
|
||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
||||
await expect.poll(async () => {
|
||||
const actual = await getConnectedPeerCount(client.page);
|
||||
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
|
||||
|
||||
return actual >= minimum;
|
||||
}, {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
||||
}).toBe(true);
|
||||
}
|
||||
|
||||
// Check chatters still have voice peers even while viewing another room
|
||||
for (const chatter of chatters) {
|
||||
await expect.poll(async () => await getConnectedPeerCount(chatter.page), {
|
||||
await expect.poll(async () => {
|
||||
const actual = await getConnectedPeerCount(chatter.page);
|
||||
const minimum = await getMinimumConnectedPeerMeshCount(chatter.page, EXPECTED_REMOTE_PEERS);
|
||||
|
||||
return actual >= minimum;
|
||||
}, {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
||||
}).toBe(true);
|
||||
}
|
||||
|
||||
if (Date.now() < deadline) {
|
||||
@@ -743,63 +764,6 @@ async function waitForLocalVoiceChannelConnection(page: Page, channelName: strin
|
||||
|
||||
// ── 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,
|
||||
|
||||
@@ -6,14 +6,21 @@ import {
|
||||
dumpRtcDiagnostics,
|
||||
getConnectedPeerCount,
|
||||
installWebRTCTracking,
|
||||
installAutoResumeAudioContext,
|
||||
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 { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||
import {
|
||||
getConnectedSignalManagerCount,
|
||||
getMinimumConnectedPeerMeshCount,
|
||||
waitForConnectedRemotePeerMesh,
|
||||
waitForConnectedSignalManagerCount
|
||||
} from '../../helpers/signal-manager';
|
||||
|
||||
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
|
||||
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
|
||||
@@ -116,8 +123,11 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
|
||||
for (const client of clients) {
|
||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||
await client.page.waitForTimeout(2_000);
|
||||
}
|
||||
|
||||
await clients[0].page.waitForTimeout(10_000);
|
||||
|
||||
for (const client of clients) {
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
}
|
||||
@@ -126,12 +136,12 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
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)
|
||||
waitForPeerConnected(client.page, 90_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)
|
||||
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
|
||||
));
|
||||
|
||||
// Wait for audio stats to appear on all clients
|
||||
@@ -146,7 +156,7 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
|
||||
// Check bidirectional audio flow on each client
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
|
||||
));
|
||||
});
|
||||
|
||||
@@ -156,7 +166,6 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -167,10 +176,15 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const client of clients) {
|
||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
||||
await expect.poll(async () => {
|
||||
const actual = await getConnectedPeerCount(client.page);
|
||||
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
|
||||
|
||||
return actual >= minimum;
|
||||
}, {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
||||
}).toBe(true);
|
||||
|
||||
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
|
||||
timeout: 10_000,
|
||||
@@ -292,7 +306,8 @@ async function createTrackedClients(
|
||||
|
||||
await installTestServerEndpoints(client.context, endpoints);
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installWebRTCTracking(client.context);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
clients.push({
|
||||
...client,
|
||||
@@ -576,124 +591,6 @@ async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise
|
||||
}, 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,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import {
|
||||
MULTI_DEVICE_PASSWORD,
|
||||
MULTI_DEVICE_VOICE_CHANNEL,
|
||||
closeClient,
|
||||
loginSecondDeviceIntoServer,
|
||||
uniqueMultiDeviceName
|
||||
} from '../../helpers/multi-device-session';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
|
||||
async function waitForVoiceMuteState(
|
||||
page: import('@playwright/test').Page,
|
||||
displayName: string,
|
||||
expectedMuted: boolean,
|
||||
timeout = 45_000
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ expectedDisplayName, expectedMuted: muted }) => {
|
||||
interface VoiceStateShape { isMuted?: boolean }
|
||||
interface UserShape { displayName: string; voiceState?: VoiceStateShape }
|
||||
interface ChannelShape { id: 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 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((userEntry) => userEntry.displayName === expectedDisplayName);
|
||||
|
||||
return entry?.voiceState?.isMuted === muted;
|
||||
},
|
||||
{ expectedDisplayName: displayName, expectedMuted },
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('Voice mute state reset', () => {
|
||||
test.describe.configure({ timeout: 300_000, retries: 1 });
|
||||
|
||||
test('clears stale mute state after abrupt disconnect and voice rejoin', async ({ createClient }) => {
|
||||
const suffix = uniqueMultiDeviceName('voice-mute-reset');
|
||||
const hostCredentials = {
|
||||
username: `host_${suffix}`,
|
||||
displayName: 'Voice Host',
|
||||
password: MULTI_DEVICE_PASSWORD
|
||||
};
|
||||
const guestCredentials = {
|
||||
username: `guest_${suffix}`,
|
||||
displayName: 'Voice Guest',
|
||||
password: MULTI_DEVICE_PASSWORD
|
||||
};
|
||||
const serverName = `Voice Mute Reset ${suffix}`;
|
||||
|
||||
let hostClient = await createClient();
|
||||
|
||||
const guestClient = await createClient();
|
||||
|
||||
await test.step('host creates the shared server', async () => {
|
||||
const registerPage = new RegisterPage(hostClient.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(hostCredentials.username, hostCredentials.displayName, hostCredentials.password);
|
||||
await expect(hostClient.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const search = new ServerSearchPage(hostClient.page);
|
||||
|
||||
await search.createServer(serverName, { description: 'Voice mute reset coverage' });
|
||||
await expect(hostClient.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
const hostRoom = new ChatRoomPage(hostClient.page);
|
||||
|
||||
await hostRoom.ensureVoiceChannelExists(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
|
||||
await test.step('guest joins the server', async () => {
|
||||
const registerPage = new RegisterPage(guestClient.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(guestCredentials.username, guestCredentials.displayName, guestCredentials.password);
|
||||
await expect(guestClient.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const search = new ServerSearchPage(guestClient.page);
|
||||
|
||||
await search.joinServerFromSearch(serverName);
|
||||
await expect(guestClient.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('host joins voice muted and guest observes the muted state', async () => {
|
||||
await hostRoom.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
await expect(hostRoom.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
await hostRoom.muteButton.click();
|
||||
|
||||
await waitForVoiceMuteState(guestClient.page, hostCredentials.displayName, true);
|
||||
});
|
||||
|
||||
await test.step('abrupt host disconnect clears stale mute before rejoin', async () => {
|
||||
await closeClient(hostClient);
|
||||
|
||||
hostClient = await createClient();
|
||||
await loginSecondDeviceIntoServer(hostClient.page, hostCredentials, serverName);
|
||||
|
||||
const reopenedRoom = new ChatRoomPage(hostClient.page);
|
||||
|
||||
await reopenedRoom.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
await expect(reopenedRoom.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await waitForVoiceMuteState(guestClient.page, hostCredentials.displayName, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { getLocalApiTokenTtlMs } from './auth-store';
|
||||
|
||||
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
describe('auth-store', () => {
|
||||
it('defaults local API tokens to a very long lifetime', () => {
|
||||
expect(getLocalApiTokenTtlMs()).toBe(TEN_YEARS_MS);
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,13 @@ export interface IssuedToken {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
const tokens = new Map<string, IssuedToken>();
|
||||
|
||||
export function getLocalApiTokenTtlMs(): number {
|
||||
return DEFAULT_TOKEN_TTL_MS;
|
||||
}
|
||||
|
||||
export function issueToken(params: {
|
||||
userId: string;
|
||||
username: string;
|
||||
@@ -24,7 +28,7 @@ export function issueToken(params: {
|
||||
const issued: IssuedToken = {
|
||||
token,
|
||||
issuedAt,
|
||||
expiresAt: issuedAt + TOKEN_TTL_MS,
|
||||
expiresAt: issuedAt + getLocalApiTokenTtlMs(),
|
||||
userId: params.userId,
|
||||
username: params.username,
|
||||
displayName: params.displayName,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { safeStorage } from 'electron';
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
writeFile
|
||||
} from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { app } from 'electron';
|
||||
|
||||
const STORAGE_DIR_NAME = 'provision-secrets';
|
||||
|
||||
function getStorageDir(): string {
|
||||
return path.join(app.getPath('userData'), STORAGE_DIR_NAME);
|
||||
}
|
||||
|
||||
function getSecretFilePath(homeUserId: string): string {
|
||||
return path.join(getStorageDir(), `${homeUserId}.bin`);
|
||||
}
|
||||
|
||||
async function ensureStorageDir(): Promise<void> {
|
||||
await mkdir(getStorageDir(), { recursive: true });
|
||||
}
|
||||
|
||||
export async function storeProvisionSecret(homeUserId: string, secret: string): Promise<boolean> {
|
||||
if (!homeUserId.trim() || !secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await ensureStorageDir();
|
||||
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
await writeFile(getSecretFilePath(homeUserId), secret, 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
const encrypted = safeStorage.encryptString(secret);
|
||||
|
||||
await writeFile(getSecretFilePath(homeUserId), encrypted);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getProvisionSecret(homeUserId: string): Promise<string | null> {
|
||||
if (!homeUserId.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = getSecretFilePath(homeUserId);
|
||||
const payload = await readFile(filePath);
|
||||
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return payload.toString('utf8');
|
||||
}
|
||||
|
||||
return safeStorage.decryptString(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -4,6 +4,10 @@ export interface AppMetricsProcessSnapshot {
|
||||
pid: number;
|
||||
type: string;
|
||||
workingSetKb: number | null;
|
||||
peakWorkingSetKb: number | null;
|
||||
privateBytesKb: number | null;
|
||||
creationTime: number | null;
|
||||
cpuPercent: number | null;
|
||||
}
|
||||
|
||||
export interface AppMetricsSnapshot {
|
||||
@@ -17,7 +21,17 @@ export function collectAppMetricsSnapshot(): AppMetricsSnapshot {
|
||||
processes: app.getAppMetrics().map((metric) => ({
|
||||
pid: metric.pid,
|
||||
type: metric.type,
|
||||
workingSetKb: metric.memory?.workingSetSize ?? null
|
||||
workingSetKb: metric.memory?.workingSetSize ?? null,
|
||||
peakWorkingSetKb: readOptionalKilobytes(metric.memory?.peakWorkingSetSize),
|
||||
privateBytesKb: readOptionalKilobytes(metric.memory?.privateBytes),
|
||||
creationTime: metric.creationTime ?? null,
|
||||
cpuPercent: typeof metric.cpu?.percentCPUUsage === 'number'
|
||||
? Math.round(metric.cpu.percentCPUUsage * 10) / 10
|
||||
: null
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function readOptionalKilobytes(value: number | undefined): number | null {
|
||||
return typeof value === 'number' && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,14 @@ import {
|
||||
setupWindowControlHandlers
|
||||
} from '../ipc';
|
||||
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
||||
import {
|
||||
attachRendererDiagnosticsHooks,
|
||||
ensurePerfDiagIpcRegistered,
|
||||
shutdownHighMemoryMonitoring,
|
||||
shutdownPerfDiagnostics,
|
||||
startHighMemoryMonitoring,
|
||||
startPerfDiagnostics
|
||||
} from '../diagnostics';
|
||||
|
||||
function startLocalApiAfterWindowReady(): void {
|
||||
setImmediate(() => {
|
||||
@@ -32,6 +40,9 @@ function startLocalApiAfterWindowReady(): void {
|
||||
}
|
||||
|
||||
export function registerAppLifecycle(): void {
|
||||
ensurePerfDiagIpcRegistered();
|
||||
startHighMemoryMonitoring();
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const dockIconPath = getDockIconPath();
|
||||
|
||||
@@ -45,7 +56,15 @@ export function registerAppLifecycle(): void {
|
||||
await migrateLegacyDesktopBranding();
|
||||
await synchronizeAutoStartSetting();
|
||||
initializeDesktopUpdater();
|
||||
startPerfDiagnostics();
|
||||
await createWindow();
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (mainWindow) {
|
||||
attachRendererDiagnosticsHooks(mainWindow);
|
||||
}
|
||||
|
||||
startLocalApiAfterWindowReady();
|
||||
startIdleMonitor();
|
||||
|
||||
@@ -67,6 +86,8 @@ export function registerAppLifecycle(): void {
|
||||
|
||||
app.on('before-quit', async (event) => {
|
||||
prepareWindowForAppQuit();
|
||||
shutdownHighMemoryMonitoring();
|
||||
await shutdownPerfDiagnostics();
|
||||
|
||||
if (getDataSource()?.isInitialized) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||
|
||||
describe('isPerfDiagEnabled', () => {
|
||||
it('returns false when the flag is unset', () => {
|
||||
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, false)).toBe(true);
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'true' }, false)).toBe(true);
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true in packaged Electron builds without env flags', () => {
|
||||
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '0' }, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false in development when the flag is unset', () => {
|
||||
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export const PERF_DIAG_ENV = 'METOYOU_PERF_DIAG';
|
||||
export const PERF_DIAG_FORCE_ENV = 'METOYOU_PERF_DIAG_FORCE';
|
||||
|
||||
const TRUTHY = new Set([
|
||||
'1',
|
||||
'true',
|
||||
'yes',
|
||||
'on'
|
||||
]);
|
||||
|
||||
function isTruthyFlag(value: string | undefined): boolean {
|
||||
return TRUTHY.has(String(value ?? '').trim()
|
||||
.toLowerCase());
|
||||
}
|
||||
|
||||
export function isPerfDiagEnabled(
|
||||
env: NodeJS.ProcessEnv,
|
||||
isPackaged: boolean
|
||||
): boolean {
|
||||
if (isPackaged) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isTruthyFlag(env[PERF_DIAG_ENV]);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
shell
|
||||
} from 'electron';
|
||||
import { collectAppMetricsSnapshot, type AppMetricsSnapshot } from '../app-metrics';
|
||||
import { getMainWindow } from '../window/create-window';
|
||||
import { resolveReadablePath } from '../path-jail';
|
||||
import { sumWorkingSetKb } from './process-metrics.rules';
|
||||
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||
import { exceedsHighMemoryThreshold } from './high-memory-alert.rules';
|
||||
import { captureHighMemoryDiagnostics } from './high-memory-capture';
|
||||
import { collectSessionContext } from './session-context.collector';
|
||||
import {
|
||||
clearHighMemoryAlert,
|
||||
readHighMemoryAlert,
|
||||
writeHighMemoryAlert,
|
||||
type HighMemoryAlertRecord
|
||||
} from './high-memory-alert.store';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import { PerfDiagWriter } from './diagnostics.writer';
|
||||
|
||||
const PROCESS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
export const HIGH_MEMORY_ALERT_PENDING_CHANNEL = 'high-memory-alert-pending';
|
||||
|
||||
let activeWriter: PerfDiagWriter | null = null;
|
||||
let processPollTimer: NodeJS.Timeout | null = null;
|
||||
let diagnosticsEnabled = false;
|
||||
let ipcRegistered = false;
|
||||
let highMemoryAlertTriggeredThisSession = false;
|
||||
let sessionStartedAt = 0;
|
||||
|
||||
export function isPerfDiagActive(): boolean {
|
||||
return diagnosticsEnabled;
|
||||
}
|
||||
|
||||
export function ensurePerfDiagIpcRegistered(): void {
|
||||
if (ipcRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
ipcRegistered = true;
|
||||
|
||||
ipcMain.handle('perf-diag-is-enabled', () => diagnosticsEnabled);
|
||||
|
||||
ipcMain.handle('perf-diag-report', (_event, entry: PerfDiagEntry) => {
|
||||
const writer = activeWriter;
|
||||
|
||||
if (!diagnosticsEnabled || !writer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
writer.append(normalizeRendererEntry(entry));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-pending-high-memory-alert', async () => {
|
||||
return readHighMemoryAlert(app.getPath('userData'));
|
||||
});
|
||||
|
||||
ipcMain.handle('acknowledge-high-memory-alert', async () => {
|
||||
await clearHighMemoryAlert(app.getPath('userData'));
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('export-high-memory-diagnostics', async () => {
|
||||
const metrics = collectAppMetricsSnapshot();
|
||||
const totalKb = sumWorkingSetKb(metrics.processes) ?? 0;
|
||||
const record = await captureHighMemoryDiagnostics({
|
||||
userDataPath: app.getPath('userData'),
|
||||
sessionStartedAt,
|
||||
metrics,
|
||||
totalWorkingSetKb: totalKb,
|
||||
writer: activeWriter,
|
||||
mainWindow: getMainWindow(),
|
||||
reason: 'manual'
|
||||
});
|
||||
|
||||
await persistAndNotifyHighMemoryAlert(record);
|
||||
|
||||
return record;
|
||||
});
|
||||
|
||||
ipcMain.handle('show-log-file-in-folder', async (_event, filePath: string) => {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) {
|
||||
return {
|
||||
shown: false,
|
||||
reason: 'missing-path'
|
||||
};
|
||||
}
|
||||
|
||||
const scopedPath = await resolveReadablePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return {
|
||||
shown: false,
|
||||
reason: 'outside-app-data'
|
||||
};
|
||||
}
|
||||
|
||||
shell.showItemInFolder(scopedPath);
|
||||
|
||||
return { shown: true };
|
||||
});
|
||||
}
|
||||
|
||||
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
||||
return activeWriter;
|
||||
}
|
||||
|
||||
export function startHighMemoryMonitoring(): void {
|
||||
ensurePerfDiagIpcRegistered();
|
||||
|
||||
if (!sessionStartedAt) {
|
||||
sessionStartedAt = Date.now();
|
||||
highMemoryAlertTriggeredThisSession = false;
|
||||
}
|
||||
|
||||
if (processPollTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sample = (): void => {
|
||||
try {
|
||||
const metrics = collectAppMetricsSnapshot();
|
||||
const totalKb = sumWorkingSetKb(metrics.processes);
|
||||
|
||||
if (activeWriter && diagnosticsEnabled) {
|
||||
activeWriter.append({
|
||||
collectedAt: metrics.collectedAt,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: {
|
||||
totalWorkingSetKb: totalKb,
|
||||
processes: metrics.processes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void maybeTriggerHighMemoryAlert(metrics, totalKb);
|
||||
} catch {
|
||||
// Collector failures must never affect the app.
|
||||
}
|
||||
};
|
||||
|
||||
sample();
|
||||
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function startPerfDiagnostics(): PerfDiagWriter | null {
|
||||
ensurePerfDiagIpcRegistered();
|
||||
startHighMemoryMonitoring();
|
||||
diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged);
|
||||
|
||||
if (!diagnosticsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = `${Date.now().toString(36)}-${process.pid}`;
|
||||
const writer = new PerfDiagWriter({
|
||||
userDataPath: app.getPath('userData'),
|
||||
sessionId
|
||||
});
|
||||
|
||||
activeWriter = writer;
|
||||
registerProcessCrashHandlers(writer);
|
||||
|
||||
const userDataPath = app.getPath('userData');
|
||||
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'session',
|
||||
payload: {
|
||||
event: 'started',
|
||||
sessionId,
|
||||
filePath: writer.snapshotFilePath
|
||||
}
|
||||
});
|
||||
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'environment',
|
||||
payload: {
|
||||
...collectSessionContext({
|
||||
sessionStartedAt,
|
||||
userDataPath
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
return writer;
|
||||
}
|
||||
|
||||
export function attachRendererDiagnosticsHooks(window: BrowserWindow): void {
|
||||
const writer = activeWriter;
|
||||
|
||||
if (!writer) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.webContents.on('render-process-gone', (_event, details) => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'crash',
|
||||
payload: {
|
||||
reason: details.reason,
|
||||
exitCode: details.exitCode
|
||||
}
|
||||
});
|
||||
|
||||
void writer.flushSnapshot('render-process-gone');
|
||||
});
|
||||
|
||||
window.webContents.on('unresponsive', () => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'unresponsive',
|
||||
payload: {}
|
||||
});
|
||||
});
|
||||
|
||||
window.webContents.on('responsive', () => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'session',
|
||||
payload: { event: 'renderer-responsive' }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function shutdownPerfDiagnostics(): Promise<void> {
|
||||
if (!activeWriter) {
|
||||
return;
|
||||
}
|
||||
|
||||
await activeWriter.flushSnapshot('shutdown');
|
||||
activeWriter = null;
|
||||
diagnosticsEnabled = false;
|
||||
}
|
||||
|
||||
export function shutdownHighMemoryMonitoring(): void {
|
||||
if (processPollTimer) {
|
||||
clearInterval(processPollTimer);
|
||||
processPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'crash',
|
||||
payload: {
|
||||
type: details.type,
|
||||
reason: details.reason,
|
||||
exitCode: details.exitCode,
|
||||
serviceName: details.serviceName ?? null,
|
||||
name: details.name ?? null
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'crash',
|
||||
payload: {
|
||||
scope: 'main-uncaughtException',
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
|
||||
void writer.flushSnapshot('uncaughtException');
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'crash',
|
||||
payload: {
|
||||
scope: 'main-unhandledRejection',
|
||||
reason: String(reason)
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeTriggerHighMemoryAlert(
|
||||
metrics: AppMetricsSnapshot,
|
||||
totalWorkingSetKb: number | null
|
||||
): Promise<void> {
|
||||
if (highMemoryAlertTriggeredThisSession || !exceedsHighMemoryThreshold(totalWorkingSetKb)) {
|
||||
return;
|
||||
}
|
||||
|
||||
highMemoryAlertTriggeredThisSession = true;
|
||||
|
||||
const record = await captureHighMemoryDiagnostics({
|
||||
userDataPath: app.getPath('userData'),
|
||||
sessionStartedAt,
|
||||
metrics,
|
||||
totalWorkingSetKb: totalWorkingSetKb ?? 0,
|
||||
writer: activeWriter,
|
||||
mainWindow: getMainWindow(),
|
||||
reason: 'threshold'
|
||||
});
|
||||
|
||||
await persistAndNotifyHighMemoryAlert(record);
|
||||
}
|
||||
|
||||
async function persistAndNotifyHighMemoryAlert(record: HighMemoryAlertRecord): Promise<void> {
|
||||
await writeHighMemoryAlert(app.getPath('userData'), record);
|
||||
notifyHighMemoryAlert(record);
|
||||
}
|
||||
|
||||
function notifyHighMemoryAlert(record: HighMemoryAlertRecord): void {
|
||||
getMainWindow()?.webContents.send(HIGH_MEMORY_ALERT_PENDING_CHANNEL, record);
|
||||
}
|
||||
|
||||
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
||||
return {
|
||||
collectedAt: Number(entry.collectedAt) || Date.now(),
|
||||
source: 'renderer',
|
||||
type: entry.type,
|
||||
payload: entry.payload ?? {}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export type PerfDiagSource = 'main' | 'renderer';
|
||||
|
||||
export type PerfDiagEntryType =
|
||||
| 'session'
|
||||
| 'environment'
|
||||
| 'process'
|
||||
| 'store'
|
||||
| 'components'
|
||||
| 'heap'
|
||||
| 'high-memory'
|
||||
| 'crash'
|
||||
| 'unresponsive';
|
||||
|
||||
export interface PerfDiagEntry {
|
||||
collectedAt: number;
|
||||
source: PerfDiagSource;
|
||||
type: PerfDiagEntryType;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import {
|
||||
formatPerfDiagLine,
|
||||
pushRingBuffer,
|
||||
resolveDiagnosticsFilePath
|
||||
} from './diagnostics.rules';
|
||||
|
||||
describe('pushRingBuffer', () => {
|
||||
it('appends items until capacity is reached', () => {
|
||||
expect(pushRingBuffer([1, 2], 3, 4)).toEqual([
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the oldest items when capacity is exceeded', () => {
|
||||
expect(pushRingBuffer([
|
||||
1,
|
||||
2,
|
||||
3
|
||||
], 4, 3)).toEqual([
|
||||
2,
|
||||
3,
|
||||
4
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPerfDiagLine', () => {
|
||||
it('serializes one JSON object per line', () => {
|
||||
const line = formatPerfDiagLine({
|
||||
collectedAt: 1_700_000_000_000,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: { browserKb: 128 }
|
||||
});
|
||||
|
||||
expect(line).toBe('{"collectedAt":1700000000000,"source":"main","type":"process","payload":{"browserKb":128}}');
|
||||
expect(line.endsWith('\n')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDiagnosticsFilePath', () => {
|
||||
it('places session files under diagnostics/', () => {
|
||||
expect(resolveDiagnosticsFilePath('/tmp/user-data', 'session-1'))
|
||||
.toBe('/tmp/user-data/diagnostics/perf-session-1.jsonl');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as path from 'path';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
|
||||
export function pushRingBuffer<T>(items: readonly T[], item: T, capacity: number): T[] {
|
||||
const next = [...items, item];
|
||||
|
||||
if (next.length <= capacity) {
|
||||
return next;
|
||||
}
|
||||
|
||||
return next.slice(next.length - capacity);
|
||||
}
|
||||
|
||||
export function formatPerfDiagLine(entry: PerfDiagEntry): string {
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
|
||||
export function resolveDiagnosticsFilePath(userDataPath: string, sessionId: string): string {
|
||||
return path.join(userDataPath, 'diagnostics', `perf-${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
export function resolveDiagnosticsDirectory(userDataPath: string): string {
|
||||
return path.join(userDataPath, 'diagnostics');
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import {
|
||||
formatPerfDiagLine,
|
||||
pushRingBuffer,
|
||||
resolveDiagnosticsFilePath
|
||||
} from './diagnostics.rules';
|
||||
|
||||
const DEFAULT_RING_CAPACITY = 300;
|
||||
const FLUSH_DEBOUNCE_MS = 250;
|
||||
|
||||
export interface PerfDiagWriterOptions {
|
||||
userDataPath: string;
|
||||
sessionId: string;
|
||||
ringCapacity?: number;
|
||||
}
|
||||
|
||||
export class PerfDiagWriter {
|
||||
private readonly filePath: string;
|
||||
private readonly sessionIdValue: string;
|
||||
private readonly ringCapacity: number;
|
||||
private readonly pendingLines: string[] = [];
|
||||
private ring: PerfDiagEntry[] = [];
|
||||
private flushTimer: NodeJS.Timeout | null = null;
|
||||
private flushInFlight: Promise<void> | null = null;
|
||||
private disabled = false;
|
||||
|
||||
constructor(options: PerfDiagWriterOptions) {
|
||||
this.sessionIdValue = options.sessionId;
|
||||
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
||||
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
||||
}
|
||||
|
||||
get sessionId(): string {
|
||||
return this.sessionIdValue;
|
||||
}
|
||||
|
||||
get snapshotFilePath(): string {
|
||||
return this.filePath;
|
||||
}
|
||||
|
||||
get bufferedEntries(): readonly PerfDiagEntry[] {
|
||||
return this.ring;
|
||||
}
|
||||
|
||||
append(entry: PerfDiagEntry): void {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ring = pushRingBuffer(this.ring, entry, this.ringCapacity);
|
||||
this.pendingLines.push(`${formatPerfDiagLine(entry)}\n`);
|
||||
this.scheduleFlush();
|
||||
} catch {
|
||||
this.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.disabled || this.pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.flushInFlight) {
|
||||
await this.flushInFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = this.pendingLines.splice(0, this.pendingLines.length);
|
||||
|
||||
this.flushInFlight = this.writeLines(lines)
|
||||
.catch(() => {
|
||||
this.disabled = true;
|
||||
})
|
||||
.finally(() => {
|
||||
this.flushInFlight = null;
|
||||
});
|
||||
|
||||
await this.flushInFlight;
|
||||
}
|
||||
|
||||
async flushSnapshot(label: string): Promise<void> {
|
||||
this.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'session',
|
||||
payload: {
|
||||
event: label,
|
||||
filePath: this.filePath,
|
||||
entries: this.ring
|
||||
}
|
||||
});
|
||||
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (this.flushTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
void this.flush();
|
||||
}, FLUSH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async writeLines(lines: string[]): Promise<void> {
|
||||
await fsp.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await fsp.appendFile(this.filePath, lines.join(''), 'utf8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import {
|
||||
exceedsHighMemoryThreshold,
|
||||
formatWorkingSetGb,
|
||||
HIGH_MEMORY_THRESHOLD_KB
|
||||
} from './high-memory-alert.rules';
|
||||
|
||||
describe('high-memory-alert.rules', () => {
|
||||
it('uses a 2 GiB working-set threshold', () => {
|
||||
expect(HIGH_MEMORY_THRESHOLD_KB).toBe(2 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('detects totals at or above the threshold', () => {
|
||||
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB - 1)).toBe(false);
|
||||
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB)).toBe(true);
|
||||
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB + 1024)).toBe(true);
|
||||
});
|
||||
|
||||
it('formats working set totals in gigabytes', () => {
|
||||
expect(formatWorkingSetGb(1536 * 1024)).toBe('1.50');
|
||||
expect(formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB)).toBe('2.00');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/** 2 GiB working-set threshold for writing a diagnostics snapshot. */
|
||||
export const HIGH_MEMORY_THRESHOLD_KB = 2 * 1024 * 1024;
|
||||
|
||||
export function exceedsHighMemoryThreshold(totalWorkingSetKb: number | null | undefined): boolean {
|
||||
return typeof totalWorkingSetKb === 'number'
|
||||
&& totalWorkingSetKb >= HIGH_MEMORY_THRESHOLD_KB;
|
||||
}
|
||||
|
||||
export function formatWorkingSetGb(totalWorkingSetKb: number): string {
|
||||
return (totalWorkingSetKb / (1024 * 1024)).toFixed(2);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import {
|
||||
clearHighMemoryAlert,
|
||||
readHighMemoryAlert,
|
||||
resolveHighMemoryAlertPath,
|
||||
writeHighMemoryAlert
|
||||
} from './high-memory-alert.store';
|
||||
|
||||
describe('high-memory-alert.store', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})));
|
||||
});
|
||||
|
||||
it('writes and reads a pending startup alert record', async () => {
|
||||
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
|
||||
|
||||
tempDirs.push(userDataPath);
|
||||
|
||||
const record = {
|
||||
logFilePath: path.join(userDataPath, 'diagnostics', 'perf-session.jsonl'),
|
||||
detectedAt: 1_700_000_000_000,
|
||||
peakWorkingSetKb: 2_200_000,
|
||||
sessionId: 'session-1',
|
||||
reason: 'threshold' as const
|
||||
};
|
||||
|
||||
await writeHighMemoryAlert(userDataPath, record);
|
||||
|
||||
expect(resolveHighMemoryAlertPath(userDataPath)).toBe(
|
||||
path.join(userDataPath, 'diagnostics', 'high-memory-pending.json')
|
||||
);
|
||||
|
||||
expect(await readHighMemoryAlert(userDataPath)).toEqual(record);
|
||||
});
|
||||
|
||||
it('clears the pending startup alert record', async () => {
|
||||
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
|
||||
|
||||
tempDirs.push(userDataPath);
|
||||
|
||||
await writeHighMemoryAlert(userDataPath, {
|
||||
logFilePath: '/tmp/perf.jsonl',
|
||||
detectedAt: Date.now(),
|
||||
peakWorkingSetKb: 2_100_000,
|
||||
sessionId: 'session-2'
|
||||
});
|
||||
|
||||
await clearHighMemoryAlert(userDataPath);
|
||||
|
||||
expect(await readHighMemoryAlert(userDataPath)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export type HighMemoryAlertReason = 'manual' | 'threshold';
|
||||
|
||||
export interface HighMemoryAlertRecord {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
reason?: HighMemoryAlertReason;
|
||||
}
|
||||
|
||||
export function resolveHighMemoryAlertPath(userDataPath: string): string {
|
||||
return path.join(userDataPath, 'diagnostics', 'high-memory-pending.json');
|
||||
}
|
||||
|
||||
export async function readHighMemoryAlert(userDataPath: string): Promise<HighMemoryAlertRecord | null> {
|
||||
try {
|
||||
const raw = await fsp.readFile(resolveHighMemoryAlertPath(userDataPath), 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<HighMemoryAlertRecord>;
|
||||
|
||||
if (
|
||||
typeof parsed.logFilePath !== 'string'
|
||||
|| !parsed.logFilePath.trim()
|
||||
|| typeof parsed.detectedAt !== 'number'
|
||||
|| typeof parsed.peakWorkingSetKb !== 'number'
|
||||
|| typeof parsed.sessionId !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
logFilePath: parsed.logFilePath,
|
||||
detectedAt: parsed.detectedAt,
|
||||
peakWorkingSetKb: parsed.peakWorkingSetKb,
|
||||
sessionId: parsed.sessionId,
|
||||
...(parsed.reason === 'manual' || parsed.reason === 'threshold'
|
||||
? { reason: parsed.reason }
|
||||
: {})
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeHighMemoryAlert(
|
||||
userDataPath: string,
|
||||
record: HighMemoryAlertRecord
|
||||
): Promise<void> {
|
||||
const filePath = resolveHighMemoryAlertPath(userDataPath);
|
||||
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fsp.writeFile(filePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
export async function clearHighMemoryAlert(userDataPath: string): Promise<void> {
|
||||
try {
|
||||
await fsp.unlink(resolveHighMemoryAlertPath(userDataPath));
|
||||
} catch {
|
||||
// Missing pending alert is fine.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as fsp from 'fs/promises';
|
||||
|
||||
import { captureHighMemoryDiagnostics } from './high-memory-capture';
|
||||
|
||||
vi.mock('./immediate-renderer-samples.collector', () => ({
|
||||
collectImmediateRendererSamples: vi.fn(async () => [])
|
||||
}));
|
||||
|
||||
vi.mock('./session-context.collector', () => ({
|
||||
collectSessionContext: vi.fn(() => ({
|
||||
platform: 'linux',
|
||||
userDataPath: '/tmp/user-data'
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('captureHighMemoryDiagnostics', () => {
|
||||
let userDataPath = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-capture-'));
|
||||
});
|
||||
|
||||
it('writes a diagnostics snapshot and returns an alert record', async () => {
|
||||
const record = await captureHighMemoryDiagnostics({
|
||||
userDataPath,
|
||||
sessionStartedAt: Date.now() - 60_000,
|
||||
metrics: {
|
||||
collectedAt: Date.now(),
|
||||
processes: [
|
||||
{
|
||||
pid: 1,
|
||||
type: 'Browser',
|
||||
workingSetKb: 2_200_000
|
||||
}
|
||||
]
|
||||
},
|
||||
totalWorkingSetKb: 2_200_000,
|
||||
writer: null,
|
||||
mainWindow: null,
|
||||
reason: 'manual'
|
||||
});
|
||||
|
||||
expect(record.peakWorkingSetKb).toBe(2_200_000);
|
||||
expect(record.reason).toBe('manual');
|
||||
expect(record.logFilePath).toContain(userDataPath);
|
||||
await expect(fsp.stat(record.logFilePath)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { AppMetricsSnapshot } from '../app-metrics';
|
||||
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules';
|
||||
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
|
||||
import { collectSessionContext } from './session-context.collector';
|
||||
import type { HighMemoryAlertRecord } from './high-memory-alert.store';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import { PerfDiagWriter } from './diagnostics.writer';
|
||||
|
||||
export type HighMemoryCaptureReason = 'manual' | 'threshold';
|
||||
|
||||
export interface CaptureHighMemoryDiagnosticsInput {
|
||||
userDataPath: string;
|
||||
sessionStartedAt: number;
|
||||
metrics: AppMetricsSnapshot;
|
||||
totalWorkingSetKb: number;
|
||||
writer: PerfDiagWriter | null;
|
||||
mainWindow: BrowserWindow | null;
|
||||
reason: HighMemoryCaptureReason;
|
||||
}
|
||||
|
||||
export async function captureHighMemoryDiagnostics(
|
||||
input: CaptureHighMemoryDiagnosticsInput
|
||||
): Promise<HighMemoryAlertRecord> {
|
||||
const detectedAt = Date.now();
|
||||
const writer = input.writer ?? new PerfDiagWriter({
|
||||
userDataPath: input.userDataPath,
|
||||
sessionId: `${input.reason}-${detectedAt.toString(36)}-${process.pid}`
|
||||
});
|
||||
const immediateRendererEntries = await collectImmediateRendererSamples(input.mainWindow);
|
||||
const environment = collectSessionContext({
|
||||
sessionStartedAt: input.sessionStartedAt,
|
||||
userDataPath: input.userDataPath
|
||||
});
|
||||
|
||||
appendEntries(writer, immediateRendererEntries);
|
||||
appendEntries(writer, [
|
||||
{
|
||||
collectedAt: detectedAt,
|
||||
source: 'main',
|
||||
type: 'environment',
|
||||
payload: {
|
||||
...environment
|
||||
}
|
||||
},
|
||||
{
|
||||
collectedAt: detectedAt,
|
||||
source: 'main',
|
||||
type: 'high-memory',
|
||||
payload: buildHighMemoryDiagnosticPayload({
|
||||
detectedAt,
|
||||
totalWorkingSetKb: input.totalWorkingSetKb,
|
||||
metrics: input.metrics,
|
||||
environment,
|
||||
mainProcessMemory: process.memoryUsage(),
|
||||
ringEntries: writer.bufferedEntries,
|
||||
immediateRendererEntries,
|
||||
sessionId: writer.sessionId
|
||||
})
|
||||
}
|
||||
]);
|
||||
|
||||
await writer.flushSnapshot(
|
||||
input.reason === 'manual' ? 'manual-export' : 'high-memory-threshold'
|
||||
);
|
||||
|
||||
return {
|
||||
logFilePath: writer.snapshotFilePath,
|
||||
detectedAt,
|
||||
peakWorkingSetKb: input.totalWorkingSetKb,
|
||||
sessionId: writer.sessionId,
|
||||
reason: input.reason
|
||||
};
|
||||
}
|
||||
|
||||
function appendEntries(writer: PerfDiagWriter, entries: readonly PerfDiagEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
writer.append(entry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import {
|
||||
buildHighMemoryDiagnosticPayload,
|
||||
buildHighMemorySummary,
|
||||
extractLatestRendererSamples,
|
||||
extractProcessHistory,
|
||||
formatMemoryUsageMb,
|
||||
rankProcessesByWorkingSet,
|
||||
summarizeRingBuffer
|
||||
} from './high-memory-snapshot.rules';
|
||||
|
||||
function createProcess(overrides: Partial<{
|
||||
pid: number;
|
||||
type: string;
|
||||
workingSetKb: number | null;
|
||||
peakWorkingSetKb: number | null;
|
||||
privateBytesKb: number | null;
|
||||
creationTime: number | null;
|
||||
cpuPercent: number | null;
|
||||
}> = {}) {
|
||||
return {
|
||||
pid: 1,
|
||||
type: 'Tab',
|
||||
workingSetKb: 1024,
|
||||
peakWorkingSetKb: null,
|
||||
privateBytesKb: null,
|
||||
creationTime: null,
|
||||
cpuPercent: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('high-memory-snapshot.rules', () => {
|
||||
it('ranks processes by working set and computes share percentages', () => {
|
||||
const tabProcess = createProcess({ pid: 1, type: 'Tab', workingSetKb: 512_000 });
|
||||
const gpuProcess = createProcess({ pid: 2, type: 'GPU', workingSetKb: 1_536_000 });
|
||||
const ranked = rankProcessesByWorkingSet([tabProcess, gpuProcess], 2_048_000);
|
||||
|
||||
expect(ranked[0]?.type).toBe('GPU');
|
||||
expect(ranked[0]?.sharePercent).toBe(75);
|
||||
expect(ranked[1]?.sharePercent).toBe(25);
|
||||
});
|
||||
|
||||
it('extracts the latest renderer store, heap, and component samples', () => {
|
||||
const entries: PerfDiagEntry[] = [
|
||||
{
|
||||
collectedAt: 1,
|
||||
source: 'renderer',
|
||||
type: 'store',
|
||||
payload: { domains: { chat: 100 } }
|
||||
},
|
||||
{
|
||||
collectedAt: 2,
|
||||
source: 'renderer',
|
||||
type: 'heap',
|
||||
payload: { usedJsHeapMb: 120 }
|
||||
},
|
||||
{
|
||||
collectedAt: 3,
|
||||
source: 'renderer',
|
||||
type: 'components',
|
||||
payload: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] }
|
||||
},
|
||||
{
|
||||
collectedAt: 4,
|
||||
source: 'renderer',
|
||||
type: 'store',
|
||||
payload: { domains: { chat: 500 } }
|
||||
}
|
||||
];
|
||||
|
||||
expect(extractLatestRendererSamples(entries)).toEqual({
|
||||
store: { domains: { chat: 500 } },
|
||||
heap: { usedJsHeapMb: 120 },
|
||||
components: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] }
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts recent process history from the ring buffer', () => {
|
||||
const entries: PerfDiagEntry[] = [
|
||||
{
|
||||
collectedAt: 1,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: { totalWorkingSetKb: 1000 }
|
||||
},
|
||||
{
|
||||
collectedAt: 2,
|
||||
source: 'main',
|
||||
type: 'session',
|
||||
payload: { event: 'noop' }
|
||||
},
|
||||
{
|
||||
collectedAt: 3,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: { totalWorkingSetKb: 2000 }
|
||||
}
|
||||
];
|
||||
|
||||
expect(extractProcessHistory(entries)).toEqual([{ collectedAt: 1, totalWorkingSetKb: 1000 }, { collectedAt: 3, totalWorkingSetKb: 2000 }]);
|
||||
});
|
||||
|
||||
it('summarizes ring buffer entry counts', () => {
|
||||
expect(summarizeRingBuffer([
|
||||
{ collectedAt: 1, source: 'main', type: 'process', payload: {} },
|
||||
{ collectedAt: 2, source: 'renderer', type: 'heap', payload: {} },
|
||||
{ collectedAt: 3, source: 'main', type: 'process', payload: {} }
|
||||
])).toEqual({
|
||||
'main:process': 2,
|
||||
'renderer:heap': 1
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a high-memory summary with threshold context', () => {
|
||||
const summary = buildHighMemorySummary(
|
||||
2_200_000,
|
||||
[createProcess({ workingSetKb: 2_200_000 })],
|
||||
1_700_000_000_000
|
||||
);
|
||||
|
||||
expect(summary.totalWorkingSetGb).toBe('2.10');
|
||||
expect(summary.thresholdGb).toBe('2.00');
|
||||
expect(summary.topProcesses).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('builds a comprehensive high-memory diagnostic payload', () => {
|
||||
const payload = buildHighMemoryDiagnosticPayload({
|
||||
detectedAt: 1_700_000_000_000,
|
||||
totalWorkingSetKb: 2_200_000,
|
||||
metrics: {
|
||||
collectedAt: 1_700_000_000_000,
|
||||
processes: [
|
||||
createProcess({
|
||||
workingSetKb: 2_200_000,
|
||||
peakWorkingSetKb: 2_300_000,
|
||||
privateBytesKb: 1_800_000,
|
||||
creationTime: 1,
|
||||
cpuPercent: 12
|
||||
})
|
||||
]
|
||||
},
|
||||
environment: { appVersion: '1.0.0' },
|
||||
mainProcessMemory: {
|
||||
rss: 64 * 1024 * 1024,
|
||||
heapTotal: 32 * 1024 * 1024,
|
||||
heapUsed: 16 * 1024 * 1024,
|
||||
external: 8 * 1024 * 1024,
|
||||
arrayBuffers: 1024
|
||||
},
|
||||
ringEntries: [
|
||||
{
|
||||
collectedAt: 1,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: { totalWorkingSetKb: 2_000_000 }
|
||||
}
|
||||
],
|
||||
immediateRendererEntries: [
|
||||
{
|
||||
collectedAt: 2,
|
||||
source: 'renderer',
|
||||
type: 'heap',
|
||||
payload: { usedJsHeapMb: 300, route: '/room/abc' }
|
||||
}
|
||||
],
|
||||
sessionId: 'session-1'
|
||||
});
|
||||
|
||||
expect(payload.event).toBe('high-memory-threshold');
|
||||
expect(payload.summary).toMatchObject({
|
||||
totalWorkingSetKb: 2_200_000
|
||||
});
|
||||
|
||||
expect(payload.processHistory).toHaveLength(1);
|
||||
expect(payload.recentRendererSamples).toEqual({
|
||||
store: null,
|
||||
heap: { usedJsHeapMb: 300, route: '/room/abc' },
|
||||
components: null
|
||||
});
|
||||
|
||||
expect(formatMemoryUsageMb({
|
||||
rss: 64 * 1024 * 1024,
|
||||
heapTotal: 32 * 1024 * 1024,
|
||||
heapUsed: 16 * 1024 * 1024,
|
||||
external: 8 * 1024 * 1024,
|
||||
arrayBuffers: 1024
|
||||
})).toEqual({
|
||||
rssMb: 64,
|
||||
heapTotalMb: 32,
|
||||
heapUsedMb: 16,
|
||||
externalMb: 8,
|
||||
arrayBuffersMb: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { AppMetricsProcessSnapshot, AppMetricsSnapshot } from '../app-metrics';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import { formatWorkingSetGb, HIGH_MEMORY_THRESHOLD_KB } from './high-memory-alert.rules';
|
||||
import type { SessionContextSnapshot } from './session-context.collector';
|
||||
|
||||
export interface RankedProcessSnapshot extends AppMetricsProcessSnapshot {
|
||||
sharePercent: number;
|
||||
}
|
||||
|
||||
export interface HighMemorySummary {
|
||||
detectedAt: number;
|
||||
thresholdKb: number;
|
||||
thresholdGb: string;
|
||||
totalWorkingSetKb: number;
|
||||
totalWorkingSetGb: string;
|
||||
topProcesses: RankedProcessSnapshot[];
|
||||
}
|
||||
|
||||
export interface LatestRendererSamples {
|
||||
store: Record<string, unknown> | null;
|
||||
heap: Record<string, unknown> | null;
|
||||
components: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export function rankProcessesByWorkingSet(
|
||||
processes: readonly AppMetricsProcessSnapshot[],
|
||||
totalWorkingSetKb: number | null
|
||||
): RankedProcessSnapshot[] {
|
||||
const total = totalWorkingSetKb ?? 0;
|
||||
|
||||
return [...processes]
|
||||
.filter((process) => process.workingSetKb != null && process.workingSetKb > 0)
|
||||
.sort((left, right) => (right.workingSetKb ?? 0) - (left.workingSetKb ?? 0))
|
||||
.map((process) => ({
|
||||
...process,
|
||||
sharePercent: total > 0
|
||||
? Math.round(((process.workingSetKb ?? 0) / total) * 1000) / 10
|
||||
: 0
|
||||
}));
|
||||
}
|
||||
|
||||
export function extractLatestRendererSamples(entries: readonly PerfDiagEntry[]): LatestRendererSamples {
|
||||
let store: Record<string, unknown> | null = null;
|
||||
let heap: Record<string, unknown> | null = null;
|
||||
let components: Record<string, unknown> | null = null;
|
||||
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
|
||||
if (entry.source !== 'renderer') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!store && entry.type === 'store') {
|
||||
store = entry.payload;
|
||||
}
|
||||
|
||||
if (!heap && entry.type === 'heap') {
|
||||
heap = entry.payload;
|
||||
}
|
||||
|
||||
if (!components && entry.type === 'components') {
|
||||
components = entry.payload;
|
||||
}
|
||||
|
||||
if (store && heap && components) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
store,
|
||||
heap,
|
||||
components
|
||||
};
|
||||
}
|
||||
|
||||
export function extractProcessHistory(
|
||||
entries: readonly PerfDiagEntry[],
|
||||
limit = 24
|
||||
): Record<string, unknown>[] {
|
||||
const history: Record<string, unknown>[] = [];
|
||||
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
|
||||
if (entry.type !== 'process') {
|
||||
continue;
|
||||
}
|
||||
|
||||
history.unshift({
|
||||
collectedAt: entry.collectedAt,
|
||||
...entry.payload
|
||||
});
|
||||
|
||||
if (history.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
export function summarizeRingBuffer(entries: readonly PerfDiagEntry[]): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.source}:${entry.type}`;
|
||||
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function buildHighMemorySummary(
|
||||
totalWorkingSetKb: number,
|
||||
processes: readonly AppMetricsProcessSnapshot[],
|
||||
detectedAt: number
|
||||
): HighMemorySummary {
|
||||
return {
|
||||
detectedAt,
|
||||
thresholdKb: HIGH_MEMORY_THRESHOLD_KB,
|
||||
thresholdGb: formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB),
|
||||
totalWorkingSetKb,
|
||||
totalWorkingSetGb: formatWorkingSetGb(totalWorkingSetKb),
|
||||
topProcesses: rankProcessesByWorkingSet(processes, totalWorkingSetKb).slice(0, 12)
|
||||
};
|
||||
}
|
||||
|
||||
export function formatMemoryUsageMb(memoryUsage: NodeJS.MemoryUsage): Record<string, number> {
|
||||
return {
|
||||
rssMb: roundMb(memoryUsage.rss),
|
||||
heapTotalMb: roundMb(memoryUsage.heapTotal),
|
||||
heapUsedMb: roundMb(memoryUsage.heapUsed),
|
||||
externalMb: roundMb(memoryUsage.external),
|
||||
arrayBuffersMb: roundMb(memoryUsage.arrayBuffers ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHighMemoryDiagnosticPayload(input: {
|
||||
detectedAt: number;
|
||||
totalWorkingSetKb: number;
|
||||
metrics: AppMetricsSnapshot;
|
||||
environment: SessionContextSnapshot;
|
||||
mainProcessMemory: NodeJS.MemoryUsage;
|
||||
ringEntries: readonly PerfDiagEntry[];
|
||||
immediateRendererEntries: readonly PerfDiagEntry[];
|
||||
sessionId: string;
|
||||
}): Record<string, unknown> {
|
||||
const mergedRingEntries = [...input.ringEntries, ...input.immediateRendererEntries];
|
||||
const recentRendererSamples = extractLatestRendererSamples(mergedRingEntries);
|
||||
|
||||
return {
|
||||
event: 'high-memory-threshold',
|
||||
sessionId: input.sessionId,
|
||||
summary: buildHighMemorySummary(
|
||||
input.totalWorkingSetKb,
|
||||
input.metrics.processes,
|
||||
input.detectedAt
|
||||
),
|
||||
environment: input.environment,
|
||||
metrics: input.metrics,
|
||||
mainProcessMemory: input.mainProcessMemory,
|
||||
mainProcessMemoryMb: formatMemoryUsageMb(input.mainProcessMemory),
|
||||
processHistory: extractProcessHistory(mergedRingEntries),
|
||||
ringSummary: summarizeRingBuffer(mergedRingEntries),
|
||||
recentRendererSamples,
|
||||
immediateRendererSamples: input.immediateRendererEntries.map((entry) => ({
|
||||
collectedAt: entry.collectedAt,
|
||||
type: entry.type,
|
||||
payload: entry.payload
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function roundMb(bytes: number): number {
|
||||
return Math.round((bytes / (1024 * 1024)) * 100) / 100;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
|
||||
export async function collectImmediateRendererSamples(
|
||||
window: BrowserWindow | null | undefined
|
||||
): Promise<PerfDiagEntry[]> {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.webContents.executeJavaScript(`
|
||||
(function () {
|
||||
const collect = globalThis.__collectPerfDiagSample;
|
||||
|
||||
return typeof collect === 'function' ? collect() : [];
|
||||
})()
|
||||
`, true);
|
||||
|
||||
if (!Array.isArray(result)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result
|
||||
.filter((entry) => entry && typeof entry === 'object')
|
||||
.map((entry) => normalizeImmediateRendererEntry(entry as Partial<PerfDiagEntry>));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImmediateRendererEntry(entry: Partial<PerfDiagEntry>): PerfDiagEntry {
|
||||
return {
|
||||
collectedAt: Number(entry.collectedAt) || Date.now(),
|
||||
source: 'renderer',
|
||||
type: entry.type ?? 'session',
|
||||
payload: entry.payload ?? {}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
|
||||
export {
|
||||
clearHighMemoryAlert,
|
||||
readHighMemoryAlert,
|
||||
resolveHighMemoryAlertPath,
|
||||
writeHighMemoryAlert
|
||||
} from './high-memory-alert.store';
|
||||
export type { HighMemoryAlertRecord } from './high-memory-alert.store';
|
||||
export {
|
||||
exceedsHighMemoryThreshold,
|
||||
formatWorkingSetGb,
|
||||
HIGH_MEMORY_THRESHOLD_KB
|
||||
} from './high-memory-alert.rules';
|
||||
export {
|
||||
attachRendererDiagnosticsHooks,
|
||||
ensurePerfDiagIpcRegistered,
|
||||
getActivePerfDiagWriter,
|
||||
HIGH_MEMORY_ALERT_PENDING_CHANNEL,
|
||||
isPerfDiagActive,
|
||||
shutdownHighMemoryMonitoring,
|
||||
shutdownPerfDiagnostics,
|
||||
startHighMemoryMonitoring,
|
||||
startPerfDiagnostics
|
||||
} from './diagnostics.lifecycle';
|
||||
export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models';
|
||||
export { PerfDiagWriter } from './diagnostics.writer';
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface ProcessWorkingSetSnapshot {
|
||||
workingSetKb: number | null;
|
||||
}
|
||||
|
||||
export function sumWorkingSetKb(processes: readonly ProcessWorkingSetSnapshot[]): number | null {
|
||||
let total = 0;
|
||||
let hasAny = false;
|
||||
|
||||
for (const process of processes) {
|
||||
if (process.workingSetKb == null || process.workingSetKb < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
total += process.workingSetKb;
|
||||
hasAny = true;
|
||||
}
|
||||
|
||||
return hasAny ? total : null;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import * as os from 'os';
|
||||
|
||||
export interface SessionWindowSnapshot {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string | null;
|
||||
focused: boolean;
|
||||
visible: boolean;
|
||||
destroyed: boolean;
|
||||
}
|
||||
|
||||
export interface SessionContextSnapshot {
|
||||
collectedAt: number;
|
||||
sessionStartedAt: number;
|
||||
uptimeMs: number;
|
||||
appVersion: string;
|
||||
electronVersion: string;
|
||||
chromeVersion: string;
|
||||
nodeVersion: string;
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
osType: string;
|
||||
osRelease: string;
|
||||
osVersion: string | null;
|
||||
totalMemKb: number;
|
||||
freeMemKb: number;
|
||||
userDataPath: string;
|
||||
appPath: string;
|
||||
isPackaged: boolean;
|
||||
locale: string;
|
||||
windowCount: number;
|
||||
windows: SessionWindowSnapshot[];
|
||||
}
|
||||
|
||||
export function collectSessionContext(input: {
|
||||
sessionStartedAt: number;
|
||||
userDataPath: string;
|
||||
}): SessionContextSnapshot {
|
||||
const collectedAt = Date.now();
|
||||
|
||||
return {
|
||||
collectedAt,
|
||||
sessionStartedAt: input.sessionStartedAt,
|
||||
uptimeMs: Math.max(0, collectedAt - input.sessionStartedAt),
|
||||
appVersion: app.getVersion(),
|
||||
electronVersion: process.versions.electron ?? 'unknown',
|
||||
chromeVersion: process.versions.chrome ?? 'unknown',
|
||||
nodeVersion: process.versions.node ?? 'unknown',
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
osType: os.type(),
|
||||
osRelease: os.release(),
|
||||
osVersion: readOsVersion(),
|
||||
totalMemKb: Math.round(os.totalmem() / 1024),
|
||||
freeMemKb: Math.round(os.freemem() / 1024),
|
||||
userDataPath: input.userDataPath,
|
||||
appPath: app.getAppPath(),
|
||||
isPackaged: app.isPackaged,
|
||||
locale: app.getLocale(),
|
||||
windowCount: BrowserWindow.getAllWindows().length,
|
||||
windows: BrowserWindow.getAllWindows().map(collectWindowSnapshot)
|
||||
};
|
||||
}
|
||||
|
||||
function collectWindowSnapshot(window: BrowserWindow): SessionWindowSnapshot {
|
||||
let url: string | null = null;
|
||||
|
||||
try {
|
||||
url = window.webContents.getURL() || null;
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: window.id,
|
||||
title: window.getTitle(),
|
||||
url,
|
||||
focused: window.isFocused(),
|
||||
visible: window.isVisible(),
|
||||
destroyed: window.isDestroyed()
|
||||
};
|
||||
}
|
||||
|
||||
function readOsVersion(): string | null {
|
||||
try {
|
||||
return os.version?.() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { isReadableRegularFile } from './file-read.rules';
|
||||
|
||||
describe('file-read.rules', () => {
|
||||
it('accepts regular files', () => {
|
||||
expect(isReadableRegularFile({ isFile: () => true })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects directories and other non-file paths', () => {
|
||||
expect(isReadableRegularFile({ isFile: () => false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Stats } from 'fs';
|
||||
|
||||
/** Only regular files can be read through the read-file IPC surface. */
|
||||
export function isReadableRegularFile(stats: Pick<Stats, 'isFile'>): boolean {
|
||||
return stats.isFile();
|
||||
}
|
||||
+57
-12
@@ -20,6 +20,7 @@ import {
|
||||
type DesktopSettings
|
||||
} from '../desktop-settings';
|
||||
import { applyLocalApiSettings, getLocalApiSnapshot } from '../api';
|
||||
import { getProvisionSecret, storeProvisionSecret } from '../api/provision-secret-store';
|
||||
import {
|
||||
activateLinuxScreenShareAudioRouting,
|
||||
deactivateLinuxScreenShareAudioRouting,
|
||||
@@ -62,7 +63,12 @@ import { listRunningProcessNames } from '../process-list';
|
||||
import { detectActiveGame } from '../game-detection';
|
||||
import { collectAppMetricsSnapshot } from '../app-metrics';
|
||||
import { clearAllTokens } from '../api/auth-store';
|
||||
import { assertPathUnderUserData, grantPluginReadRoot, resolveReadablePath } from '../path-jail';
|
||||
import {
|
||||
assertPathUnderUserData,
|
||||
grantPluginReadRoot,
|
||||
resolveReadablePath
|
||||
} from '../path-jail';
|
||||
import { isReadableRegularFile } from './file-read.rules';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
|
||||
@@ -380,6 +386,14 @@ export function setupSystemHandlers(): void {
|
||||
|
||||
ipcMain.handle('get-app-metrics', () => collectAppMetricsSnapshot());
|
||||
|
||||
ipcMain.handle('store-provision-secret', async (_event, homeUserId: string, secret: string) =>
|
||||
await storeProvisionSecret(homeUserId, secret)
|
||||
);
|
||||
|
||||
ipcMain.handle('get-provision-secret', async (_event, homeUserId: string) =>
|
||||
await getProvisionSecret(homeUserId)
|
||||
);
|
||||
|
||||
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
||||
ipcMain.handle('open-current-data-folder', async () => await openCurrentDataFolder());
|
||||
ipcMain.handle('export-user-data', async () => await exportUserData());
|
||||
@@ -641,9 +655,19 @@ export function setupSystemHandlers(): void {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await fsp.readFile(scopedPath);
|
||||
try {
|
||||
const stats = await fsp.stat(scopedPath);
|
||||
|
||||
return data.toString('base64');
|
||||
if (!isReadableRegularFile(stats)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await fsp.readFile(scopedPath);
|
||||
|
||||
return data.toString('base64');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
|
||||
@@ -653,17 +677,27 @@ export function setupSystemHandlers(): void {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileHandle = await fsp.open(scopedPath, 'r');
|
||||
|
||||
try {
|
||||
const safeStart = Math.max(0, Math.trunc(start));
|
||||
const safeEnd = Math.max(safeStart, Math.trunc(end));
|
||||
const buffer = Buffer.alloc(safeEnd - safeStart);
|
||||
const result = await fileHandle.read(buffer, 0, buffer.length, safeStart);
|
||||
const stats = await fsp.stat(scopedPath);
|
||||
|
||||
return buffer.subarray(0, result.bytesRead).toString('base64');
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
if (!isReadableRegularFile(stats)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileHandle = await fsp.open(scopedPath, 'r');
|
||||
|
||||
try {
|
||||
const safeStart = Math.max(0, Math.trunc(start));
|
||||
const safeEnd = Math.max(safeStart, Math.trunc(end));
|
||||
const buffer = Buffer.alloc(safeEnd - safeStart);
|
||||
const result = await fileHandle.read(buffer, 0, buffer.length, safeStart);
|
||||
|
||||
return buffer.subarray(0, result.bytesRead).toString('base64');
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -715,6 +749,17 @@ export function setupSystemHandlers(): void {
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('append-file-bytes', async (_event, filePath: string, bytes: Uint8Array) => {
|
||||
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await fsp.appendFile(scopedPath, Buffer.from(bytes));
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-file', async (_event, filePath: string) => {
|
||||
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||
|
||||
|
||||
@@ -35,10 +35,23 @@ describe('path-jail', () => {
|
||||
await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath);
|
||||
});
|
||||
|
||||
it('accepts diagnostics log paths under diagnostics', async () => {
|
||||
const diagnosticsDir = path.join(tempRoot, 'diagnostics');
|
||||
|
||||
fs.mkdirSync(diagnosticsDir, { recursive: true });
|
||||
const logPath = path.join(diagnosticsDir, 'perf-session.jsonl');
|
||||
|
||||
fs.writeFileSync(logPath, '{}');
|
||||
|
||||
await expect(assertPathUnderRoot(tempRoot, logPath)).resolves.toBe(logPath);
|
||||
});
|
||||
|
||||
it('accepts cached plugin bundle paths under plugin-bundles', async () => {
|
||||
const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0');
|
||||
|
||||
fs.mkdirSync(bundleDir, { recursive: true });
|
||||
const bundlePath = path.join(bundleDir, 'main.js');
|
||||
|
||||
fs.writeFileSync(bundlePath, 'export default {}');
|
||||
|
||||
await expect(assertPathUnderRoot(tempRoot, bundlePath)).resolves.toBe(bundlePath);
|
||||
@@ -59,6 +72,7 @@ describe('path-jail', () => {
|
||||
it('allows user-granted plugin source roots outside app data', async () => {
|
||||
const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'metoyou-plugin-source-'));
|
||||
const manifestPath = path.join(externalRoot, 'plugin-source.json');
|
||||
|
||||
fs.writeFileSync(manifestPath, '{}');
|
||||
|
||||
grantPluginReadRoot(externalRoot);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user