Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3c2f01cc6 | |||
| dac5cb42a5 | |||
| 29032b5a36 | |||
| e75b4a38ed | |||
| 07e91a0d09 | |||
| cb59af6b6c | |||
| 6b9a39fe4a | |||
| a01abbb1bf | |||
| bdea95511d | |||
| 9981aee602 | |||
| 31962aeb1a | |||
| 79c6f91cd6 | |||
| b630bacdc6 | |||
| 1671a04f03 | |||
| cb386394d0 | |||
| 182828bb1e | |||
| 49b602dbda | |||
| d72a027c9a | |||
| b1b3d93851 | |||
| 494a05e606 | |||
| 5bf4f698df | |||
| d174536272 |
@@ -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_CONTEXT.md` — contract for updating `CONTEXT.md` / `CONTEXT-MAP.md`
|
||||||
- `agents-docs/AGENTS_ADRS.md` — contract for writing architecture decision records
|
- `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:
|
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/`
|
- **Feature docs:** `agents-docs/features/`
|
||||||
- **Architecture decisions:** `agents-docs/adr/`
|
- **Architecture decisions:** `agents-docs/adr/`
|
||||||
- **Context map:** `agents-docs/CONTEXT-MAP.md`
|
- **Context map:** `agents-docs/CONTEXT-MAP.md`
|
||||||
|
- **Obsidian bug tracker:** `agents-docs/BUG_TRACKER.md`
|
||||||
- **Product-client domain:** `toju-app/CONTEXT.md`
|
- **Product-client domain:** `toju-app/CONTEXT.md`
|
||||||
- **Desktop-shell domain:** `electron/CONTEXT.md`
|
- **Desktop-shell domain:** `electron/CONTEXT.md`
|
||||||
- **Server domain:** `server/CONTEXT.md`
|
- **Server domain:** `server/CONTEXT.md`
|
||||||
|
|||||||
90
agents-docs/BUG_TRACKER.md
Normal file
@@ -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.
|
||||||
@@ -25,6 +25,62 @@ Durable rules for AI agents working on this project. Read this file at session s
|
|||||||
|
|
||||||
## Lessons
|
## Lessons
|
||||||
|
|
||||||
|
### 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]
|
### 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.
|
- **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.
|
||||||
@@ -32,12 +88,12 @@ Durable rules for AI agents working on this project. Read this file at session s
|
|||||||
- **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.
|
- **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'])`.
|
- **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 (featured/trending) must fan out across all online endpoints like search [server-directory]
|
### Server discovery must fan out across all endpoints and self-heal on 404 — never hardcode a host capability blocklist [server-directory]
|
||||||
|
|
||||||
- **Trigger:** the `/servers` (find-servers) page showed no servers by default but found them as soon as the user typed in the search box. Discovery (`getDiscoveryServers`) queried only the *active* endpoint via `getApiBaseUrl()`, and when that endpoint is a discovery-unsupported production host (`signal.toju.app` / `signal-sweden.toju.app` in `DISCOVERY_UNSUPPORTED_HOSTS`) it short-circuited to `[]`; search meanwhile fans out across every online endpoint, so typing surfaced the servers that lived on other endpoints (e.g. localhost).
|
- **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:** make `getFeaturedServers`/`getTrendingServers` fan out across `getSearchableEndpoints()` with `forkJoin` + `deduplicateById` (mirroring all-endpoint search), and apply the `endpointSupportsServerDiscovery` gate *per endpoint* (skip → `[]`) instead of short-circuiting the whole request on the active endpoint.
|
- **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:** the empty-query find-servers view renders discovery sections, not search results, so any divergence between discovery's endpoint set and search's endpoint set makes the default view look broken while search works.
|
- **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:** `getDiscoveryServers` + `fetchDiscoveryFromEndpoint` in `server-directory-api.service.ts`; verified the live server returns 12 featured/12 trending while the active production host is gated out client-side.
|
- **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]
|
### Server registration needs `ownerPublicKey: oderId || id`, and must not be fire-and-forget [server-directory] [rooms]
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ Session-token authentication for the signaling server and product client.
|
|||||||
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only |
|
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only |
|
||||||
| Product client local DB | OS user account | SQLite and attachments are plaintext at rest |
|
| 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
|
## Login / register response
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -54,6 +60,7 @@ Require `Authorization: Bearer`:
|
|||||||
- `oderId` must match the token's user id when provided.
|
- `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.
|
- `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.
|
- 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
|
## Multi-device sessions
|
||||||
|
|
||||||
@@ -68,7 +75,8 @@ When the same account is logged in on multiple devices, account-owned data is ke
|
|||||||
|
|
||||||
| Data | Mechanism |
|
| Data | Mechanism |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Server chat messages | Existing `chat_message` relay (connection-scoped broadcast) |
|
| 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 |
|
| Voice / typing | Existing `voice_state` / `user_typing` relays |
|
||||||
| Saved servers (join/leave) | `account_sync` payload `saved-room-sync` / `saved-room-remove` |
|
| 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` |
|
| Profile avatar + card text | `account_sync` `user-avatar-full` + `user-avatar-chunk` |
|
||||||
@@ -81,7 +89,7 @@ Client rules:
|
|||||||
- `broadcastMessage()` still fans out over peer data channels; relayable events are **also** wrapped in `account_sync` and sent on the WebSocket.
|
- `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`.
|
- 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.
|
- 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, friends, profile, emoji library).
|
- 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:
|
WebSocket envelope:
|
||||||
|
|
||||||
@@ -109,13 +117,18 @@ A per-install **provision secret** enables silent account creation on newly adde
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Home login/register | `authenticateUser` | Resets local state, stores home credential + provision secret |
|
| 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 |
|
| 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>`) |
|
| 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` | Clears that URL's credential and re-provisions when home token is still valid; global logout only when home server rejects auth |
|
| Foreign auth failure | `signalServerAuthFailed` | Clears that URL's credential and re-provisions when home token is still valid; global logout only when home server rejects auth |
|
||||||
|
|
||||||
|
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.
|
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.
|
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
|
## Security considerations
|
||||||
|
|
||||||
- Rate limits: login/register (100 / 15 min), server join (30 / min).
|
- Rate limits: login/register (100 / 15 min), server join (30 / min).
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Custom emoji lets users upload small image emoji, use them in chat messages and
|
|||||||
|
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **Saved custom emoji**: A known asset the current user added to their library; saved emoji appear in the picker and shortcut ranking. Library membership is **user-bound, not client-bound** — it is tracked per signed-in user (keyed by user id), so a second account on the same device never inherits the first account's library.
|
||||||
- **Emoji shortcut row**: The seven most-used emoji entries for the current user plus an eighth control that opens the full selector.
|
- **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.
|
- **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.
|
- **Composer emoji alias**: The readable inline draft representation `:name:`. The composer rewrites known aliases to stable custom emoji tokens only when sending.
|
||||||
@@ -40,6 +40,7 @@ When a peer connects, each side sends a summary of known assets. The receiver re
|
|||||||
- Uploads are capped at 1 MB.
|
- Uploads are capped at 1 MB.
|
||||||
- Accepted image types match profile avatars: WebP, GIF, JPG, and JPEG.
|
- 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.
|
- Local shortcut ranking is keyed by the active user and includes Unicode emoji plus saved custom emoji only.
|
||||||
|
- Saved-library membership is bound to the user, not the client: `CustomEmojiService` tracks the set of saved emoji ids per user id in `localStorage` (`metoyou_custom_emoji_saved:<userId>`, mirroring the per-user usage ranking). The picker shows only emoji in the active user's saved set, so signing in as a different account on the same client never exposes the previous account's library. On first load after this change the set is seeded from legacy `savedByUser` rows the user actually created (`creatorUserId === userId`), so creators keep their library while other local accounts stay empty.
|
||||||
- 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.
|
- 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`).
|
- 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.
|
- 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.
|
||||||
@@ -50,9 +51,9 @@ When a peer connects, each side sends a summary of known assets. The receiver re
|
|||||||
|
|
||||||
## Data Access
|
## Data Access
|
||||||
|
|
||||||
- Browser runtime stores custom emoji in IndexedDB store `customEmojis`.
|
- Browser runtime stores custom emoji image assets in IndexedDB store `customEmojis` (per-user database scope).
|
||||||
- Electron runtime stores custom emoji in SQLite table `custom_emojis`, created by migration `1000000000011-AddCustomEmojis`.
|
- Electron runtime stores custom emoji image assets in SQLite table `custom_emojis`, created by migration `1000000000011-AddCustomEmojis` (a single shared desktop database).
|
||||||
- Renderer access goes through `DatabaseService` methods `saveCustomEmoji`, `getCustomEmojis`, and `deleteCustomEmoji`.
|
- Renderer access goes through `DatabaseService` methods `saveCustomEmoji`, `getCustomEmojis`, and `deleteCustomEmoji`. These persist the image **assets** only; they are not scoped per user (the Electron table is shared across local accounts). Per-user **library membership** lives separately in `localStorage` (`metoyou_custom_emoji_saved:<userId>`), which is what keeps the picker user-bound even on a shared client database.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,28 @@ 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`.
|
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
|
||||||
|
|
||||||
| Feature | Status | Notes |
|
| Feature | Status | Notes |
|
||||||
@@ -112,7 +134,9 @@ Declared in `toju-app/android/app/src/main/AndroidManifest.xml`:
|
|||||||
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
|
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
|
||||||
| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session |
|
| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session |
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
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)
|
### iOS (APNs)
|
||||||
|
|
||||||
@@ -177,9 +201,10 @@ The service shows a low-importance ongoing notification while a call is active.
|
|||||||
|
|
||||||
## Safe area (Android)
|
## 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.
|
- `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)
|
## Self-hosted HTTPS signal servers (Android)
|
||||||
|
|
||||||
@@ -231,6 +256,7 @@ Network security configs:
|
|||||||
- `MobileCallSessionService` — CallKit + foreground service + in-call notifications.
|
- `MobileCallSessionService` — CallKit + foreground service + in-call notifications.
|
||||||
- `App` bootstrap — initializes mobile persistence, lifecycle, app-update polling, call-session, and push registration wiring.
|
- `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.
|
- `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
|
## Phase 3 completion notes
|
||||||
|
|
||||||
|
|||||||
9
dev.sh
@@ -21,13 +21,14 @@ if [ "$SSL" = "true" ]; then
|
|||||||
fi
|
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"
|
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"
|
# Use 127.0.0.1 so wait-on does not hit a stale HTTP listener on localhost (::1).
|
||||||
HEALTH_URL="https://localhost:3001/api/health"
|
WAIT_URL="https://127.0.0.1:4200"
|
||||||
|
HEALTH_URL="https://127.0.0.1:3001/api/health"
|
||||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
else
|
else
|
||||||
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0"
|
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0"
|
||||||
WAIT_URL="http://localhost:4200"
|
WAIT_URL="http://127.0.0.1:4200"
|
||||||
HEALTH_URL="http://localhost:3001/api/health"
|
HEALTH_URL="http://127.0.0.1:3001/api/health"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec npx concurrently --kill-others \
|
exec npx concurrently --kill-others \
|
||||||
|
|||||||
@@ -114,10 +114,84 @@ export async function expectCrossDeviceMessage(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await sender.sendMessage(message);
|
await sender.sendMessage(message);
|
||||||
|
|
||||||
await expect.poll(async () => {
|
await expectSyncedMessage(receiver, message, timeout);
|
||||||
return await receiver.getMessageItemByText(message).isVisible()
|
}
|
||||||
|
|
||||||
|
/** 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);
|
.catch(() => false);
|
||||||
}, { timeout }).toBe(true);
|
|
||||||
|
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> {
|
async function warmClientPage(page: Page): Promise<void> {
|
||||||
@@ -181,6 +255,25 @@ export function membersSidePanel(page: Page) {
|
|||||||
return page.locator('app-rooms-side-panel').last();
|
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) {
|
export function passiveVoiceChannelJoinBadge(page: Page, channelName = MULTI_DEVICE_VOICE_CHANNEL) {
|
||||||
return page
|
return page
|
||||||
.locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`)
|
.locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`)
|
||||||
|
|||||||
76
e2e/helpers/settings-modal.ts
Normal file
@@ -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 };
|
||||||
72
e2e/helpers/signal-manager.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
49
e2e/helpers/voice-roster.ts
Normal file
@@ -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,5 +1,12 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { type BrowserContext, 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.
|
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
||||||
@@ -21,11 +28,12 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
source?: AudioScheduledSourceNode;
|
source?: AudioScheduledSourceNode;
|
||||||
drawIntervalId?: number;
|
drawIntervalId?: number;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
const harness = window as unknown as WebRtcTestHarnessWindow;
|
||||||
|
|
||||||
(window as any).__rtcConnections = connections;
|
harness.__rtcConnections = connections;
|
||||||
(window as any).__rtcDataChannels = dataChannels;
|
harness.__rtcDataChannels = dataChannels;
|
||||||
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
harness.__rtcRemoteTracks = [];
|
||||||
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
|
harness.__rtcSyntheticMediaResources = syntheticMediaResources;
|
||||||
|
|
||||||
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
||||||
const trackDataChannel = (channel: RTCDataChannel) => {
|
const trackDataChannel = (channel: RTCDataChannel) => {
|
||||||
@@ -36,7 +44,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
dataChannels.push(channel);
|
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 pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
|
||||||
const originalCreateDataChannel = pc.createDataChannel.bind(pc);
|
const originalCreateDataChannel = pc.createDataChannel.bind(pc);
|
||||||
|
|
||||||
@@ -50,7 +58,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
}) as RTCPeerConnection['createDataChannel'];
|
}) as RTCPeerConnection['createDataChannel'];
|
||||||
|
|
||||||
pc.addEventListener('connectionstatechange', () => {
|
pc.addEventListener('connectionstatechange', () => {
|
||||||
(window as any).__lastRtcState = pc.connectionState;
|
harness.__lastRtcState = pc.connectionState;
|
||||||
});
|
});
|
||||||
|
|
||||||
pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => {
|
pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => {
|
||||||
@@ -58,7 +66,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
});
|
});
|
||||||
|
|
||||||
pc.addEventListener('track', (event: RTCTrackEvent) => {
|
pc.addEventListener('track', (event: RTCTrackEvent) => {
|
||||||
(window as any).__rtcRemoteTracks.push({
|
harness.__rtcRemoteTracks.push({
|
||||||
kind: event.track.kind,
|
kind: event.track.kind,
|
||||||
id: event.track.id,
|
id: event.track.id,
|
||||||
readyState: event.track.readyState
|
readyState: event.track.readyState
|
||||||
@@ -66,10 +74,10 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
});
|
});
|
||||||
|
|
||||||
return pc;
|
return pc;
|
||||||
} as any;
|
} as typeof RTCPeerConnection;
|
||||||
|
|
||||||
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
harness.RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
||||||
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
|
Object.setPrototypeOf(harness.RTCPeerConnection, OriginalRTCPeerConnection);
|
||||||
|
|
||||||
// Patch getDisplayMedia to return a synthetic screen share stream
|
// Patch getDisplayMedia to return a synthetic screen share stream
|
||||||
// (canvas-based video + 880Hz oscillator audio) so the browser
|
// (canvas-based video + 880Hz oscillator audio) so the browser
|
||||||
@@ -144,10 +152,11 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
|
||||||
// Tag the stream so tests can identify it
|
// Tag the stream so tests can identify it
|
||||||
(resultStream as any).__isScreenShare = true;
|
(resultStream as ScreenShareMediaStream).__isScreenShare = true;
|
||||||
|
|
||||||
return resultStream;
|
return resultStream;
|
||||||
};
|
};
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,11 +178,12 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const OrigAudioContext = window.AudioContext;
|
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);
|
const ctx: AudioContext = new OrigAudioContext(...args);
|
||||||
// Track all created AudioContexts for test diagnostics
|
// Track all created AudioContexts for test diagnostics
|
||||||
const tracked = ((window as any).__trackedAudioContexts ??= []) as AudioContext[];
|
const tracked = audioHarness.__trackedAudioContexts ??= [];
|
||||||
|
|
||||||
tracked.push(ctx);
|
tracked.push(ctx);
|
||||||
|
|
||||||
@@ -189,16 +199,16 @@ export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return ctx;
|
return ctx;
|
||||||
} as any;
|
} as typeof AudioContext;
|
||||||
|
|
||||||
(window as any).AudioContext.prototype = OrigAudioContext.prototype;
|
audioHarness.AudioContext.prototype = OrigAudioContext.prototype;
|
||||||
Object.setPrototypeOf((window as any).AudioContext, OrigAudioContext);
|
Object.setPrototypeOf(audioHarness.AudioContext, OrigAudioContext);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => (window as any).__rtcConnections?.some(
|
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
) ?? false,
|
) ?? false,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -211,7 +221,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis
|
|||||||
*/
|
*/
|
||||||
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => (window as any).__rtcConnections?.some(
|
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
) ?? false
|
) ?? false
|
||||||
);
|
);
|
||||||
@@ -220,7 +230,7 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
|||||||
/** Returns the number of tracked peer connections in `connected` state. */
|
/** Returns the number of tracked peer connections in `connected` state. */
|
||||||
export async function getConnectedPeerCount(page: Page): Promise<number> {
|
export async function getConnectedPeerCount(page: Page): Promise<number> {
|
||||||
return page.evaluate(
|
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'
|
(pc) => pc.connectionState === 'connected'
|
||||||
).length ?? 0
|
).length ?? 0
|
||||||
);
|
);
|
||||||
@@ -228,19 +238,36 @@ export async function getConnectedPeerCount(page: Page): Promise<number> {
|
|||||||
|
|
||||||
/** Wait until the expected number of peer connections are `connected`. */
|
/** Wait until the expected number of peer connections are `connected`. */
|
||||||
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||||
|
try {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(count) => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||||
(pc) => pc.connectionState === 'connected'
|
(pc) => pc.connectionState === 'connected'
|
||||||
).length === count,
|
).length === count,
|
||||||
expectedCount,
|
expectedCount,
|
||||||
{ timeout }
|
{ 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. */
|
/** Returns the number of tracked RTCDataChannels in the open state. */
|
||||||
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
||||||
return page.evaluate(
|
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'
|
(channel) => channel.readyState === 'open'
|
||||||
).length ?? 0
|
).length ?? 0
|
||||||
);
|
);
|
||||||
@@ -249,7 +276,7 @@ export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
|||||||
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
||||||
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
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'
|
(channel) => channel.readyState === 'open'
|
||||||
).length === count,
|
).length === count,
|
||||||
expectedCount,
|
expectedCount,
|
||||||
@@ -260,7 +287,7 @@ export async function waitForOpenDataChannelCount(page: Page, expectedCount: num
|
|||||||
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
||||||
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
||||||
return page.evaluate(() => {
|
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;
|
let closed = 0;
|
||||||
|
|
||||||
@@ -280,7 +307,7 @@ export async function closeOpenDataChannels(page: Page): Promise<number> {
|
|||||||
/** Dispatch a synthetic data-channel error event on each open channel. */
|
/** Dispatch a synthetic data-channel error event on each open channel. */
|
||||||
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
||||||
return page.evaluate(() => {
|
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;
|
let dispatched = 0;
|
||||||
|
|
||||||
@@ -341,7 +368,7 @@ interface PerPeerAudioStat {
|
|||||||
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
|
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
|
||||||
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
|
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
|
||||||
return page.evaluate(async () => {
|
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) {
|
if (!connections?.length) {
|
||||||
return [];
|
return [];
|
||||||
@@ -358,7 +385,7 @@ export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat
|
|||||||
try {
|
try {
|
||||||
const stats = await pc.getStats();
|
const stats = await pc.getStats();
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
stats.forEach((report: RTCStats) => {
|
||||||
const kind = report.kind ?? report.mediaType;
|
const kind = report.kind ?? report.mediaType;
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'audio') {
|
if (report.type === 'outbound-rtp' && kind === 'audio') {
|
||||||
@@ -459,7 +486,7 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||||
}> {
|
}> {
|
||||||
return page.evaluate(async () => {
|
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)
|
if (!connections?.length)
|
||||||
return { outbound: null, inbound: null };
|
return { outbound: null, inbound: null };
|
||||||
@@ -473,8 +500,8 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
hasInbound: boolean;
|
hasInbound: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hwm: Record<number, HWMEntry> = (window as any).__rtcStatsHWM =
|
const hwm: Record<number, HWMEntry> = (window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM =
|
||||||
((window as any).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
((window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
||||||
|
|
||||||
for (let idx = 0; idx < connections.length; idx++) {
|
for (let idx = 0; idx < connections.length; idx++) {
|
||||||
let stats: RTCStatsReport;
|
let stats: RTCStatsReport;
|
||||||
@@ -492,7 +519,7 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
let hasOut = false;
|
let hasOut = false;
|
||||||
let hasIn = false;
|
let hasIn = false;
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
stats.forEach((report: RTCStats) => {
|
||||||
const kind = report.kind ?? report.mediaType;
|
const kind = report.kind ?? report.mediaType;
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'audio') {
|
if (report.type === 'outbound-rtp' && kind === 'audio') {
|
||||||
@@ -583,7 +610,7 @@ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promis
|
|||||||
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
async () => {
|
async () => {
|
||||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return false;
|
return false;
|
||||||
@@ -600,7 +627,7 @@ export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Pr
|
|||||||
let hasOut = false;
|
let hasOut = false;
|
||||||
let hasIn = false;
|
let hasIn = false;
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
stats.forEach((report: RTCStats) => {
|
||||||
const kind = report.kind ?? report.mediaType;
|
const kind = report.kind ?? report.mediaType;
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'audio')
|
if (report.type === 'outbound-rtp' && kind === 'audio')
|
||||||
@@ -692,7 +719,7 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||||
}> {
|
}> {
|
||||||
return page.evaluate(async () => {
|
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)
|
if (!connections?.length)
|
||||||
return { outbound: null, inbound: null };
|
return { outbound: null, inbound: null };
|
||||||
@@ -706,8 +733,8 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
hasInbound: boolean;
|
hasInbound: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hwm: Record<number, VHWM> = (window as any).__rtcVideoStatsHWM =
|
const hwm: Record<number, VHWM> = (window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM =
|
||||||
((window as any).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
((window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
||||||
|
|
||||||
for (let idx = 0; idx < connections.length; idx++) {
|
for (let idx = 0; idx < connections.length; idx++) {
|
||||||
let stats: RTCStatsReport;
|
let stats: RTCStatsReport;
|
||||||
@@ -725,7 +752,7 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
let hasOut = false;
|
let hasOut = false;
|
||||||
let hasIn = false;
|
let hasIn = false;
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
stats.forEach((report: RTCStats) => {
|
||||||
const kind = report.kind ?? report.mediaType;
|
const kind = report.kind ?? report.mediaType;
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'video') {
|
if (report.type === 'outbound-rtp' && kind === 'video') {
|
||||||
@@ -791,7 +818,7 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
async () => {
|
async () => {
|
||||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return false;
|
return false;
|
||||||
@@ -808,7 +835,7 @@ export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Pr
|
|||||||
let hasOut = false;
|
let hasOut = false;
|
||||||
let hasIn = false;
|
let hasIn = false;
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
stats.forEach((report: RTCStats) => {
|
||||||
const kind = report.kind ?? report.mediaType;
|
const kind = report.kind ?? report.mediaType;
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'video')
|
if (report.type === 'outbound-rtp' && kind === 'video')
|
||||||
@@ -959,7 +986,7 @@ export async function waitForInboundVideoFlow(
|
|||||||
*/
|
*/
|
||||||
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
||||||
return page.evaluate(async () => {
|
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)
|
if (!conns?.length)
|
||||||
return 'No connections tracked';
|
return 'No connections tracked';
|
||||||
@@ -984,7 +1011,7 @@ export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
|||||||
try {
|
try {
|
||||||
const stats = await pc.getStats();
|
const stats = await pc.getStats();
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
stats.forEach((report: RTCStats) => {
|
||||||
if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp')
|
if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp')
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -994,7 +1021,7 @@ export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
|||||||
|
|
||||||
lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`);
|
lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`);
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
lines.push(` getStats() failed: ${err?.message ?? err}`);
|
lines.push(` getStats() failed: ${err?.message ?? err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
e2e/helpers/webrtc-test-window.types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -94,6 +94,25 @@ export class ChatMessagesPage {
|
|||||||
}, files);
|
}, 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> {
|
async openGifPicker(): Promise<void> {
|
||||||
await this.waitForReady();
|
await this.waitForReady();
|
||||||
await this.gifButton.click();
|
await this.gifButton.click();
|
||||||
@@ -132,6 +151,31 @@ export class ChatMessagesPage {
|
|||||||
}).toBe(true);
|
}).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 {
|
getEmbedCardByTitle(title: string): Locator {
|
||||||
return this.page.locator('app-chat-link-embed').filter({
|
return this.page.locator('app-chat-link-embed').filter({
|
||||||
has: this.page.getByText(title, { exact: true })
|
has: this.page.getByText(title, { exact: true })
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ test.describe('Multi-device session', () => {
|
|||||||
expect(instanceA).not.toEqual(instanceB);
|
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 test.step('syncs chat from device A to device B', async () => {
|
||||||
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
||||||
});
|
});
|
||||||
|
|||||||
88
e2e/tests/auth/offline-signal-server-no-login-loop.spec.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
125
e2e/tests/chat/attachment-only-message-grouping.spec.ts
Normal file
@@ -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 { type Page } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
test,
|
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 }) => {
|
test('renders link embeds for shared links', async ({ createClient }) => {
|
||||||
const scenario = await createChatScenario(createClient);
|
const scenario = await createChatScenario(createClient);
|
||||||
const messageText = `Useful docs ${MOCK_EMBED_URL}`;
|
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 {
|
function buildMockSvgMarkup(label: string): string {
|
||||||
return [
|
return [
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
|
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
|
||||||
|
|||||||
204
e2e/tests/chat/custom-emoji-user-binding.spec.ts
Normal file
@@ -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)}`;
|
||||||
|
}
|
||||||
244
e2e/tests/chat/local-attachment-persistence.spec.ts
Normal file
@@ -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)}`;
|
||||||
|
}
|
||||||
176
e2e/tests/chat/multi-client-chat-sync.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
96
e2e/tests/chat/multi-device-attachment-sharing.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createBinaryFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
mimeType,
|
||||||
|
base64: Buffer.from(content, 'utf8').toString('base64')
|
||||||
|
};
|
||||||
|
}
|
||||||
121
e2e/tests/mobile/android-app-icon.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
e2e/tests/mobile/mobile-login-on-startup.spec.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
25
e2e/tests/mobile/mobile-settings-logout.spec.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,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 test.step('Activate the server plugin for Bob as the embed/soundboard receiver', async () => {
|
||||||
await installGrantAndActivatePlugin(scenario.bob.page, false);
|
await installRequiredServerPluginsViaModal(scenario.bob.page);
|
||||||
await closeSettingsModal(scenario.bob.page);
|
|
||||||
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
|
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
|
||||||
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
|
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||||
});
|
});
|
||||||
@@ -178,6 +177,14 @@ async function installGrantAndActivatePlugin(page: Page, installFromStore: boole
|
|||||||
await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 30_000 });
|
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> {
|
async function closeSettingsModal(page: Page): Promise<void> {
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await expect(page.getByTestId('plugin-manager')).toHaveCount(0);
|
await expect(page.getByTestId('plugin-manager')).toHaveCount(0);
|
||||||
|
|||||||
98
e2e/tests/servers/server-discovery-default.spec.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,19 +10,22 @@ import {
|
|||||||
dumpRtcDiagnostics,
|
dumpRtcDiagnostics,
|
||||||
getConnectedPeerCount,
|
getConnectedPeerCount,
|
||||||
installWebRTCTracking,
|
installWebRTCTracking,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
waitForAllPeerAudioFlow,
|
waitForAllPeerAudioFlow,
|
||||||
waitForAudioStatsPresent,
|
waitForAudioStatsPresent,
|
||||||
waitForConnectedPeerCount,
|
|
||||||
waitForPeerConnected
|
waitForPeerConnected
|
||||||
} from '../../helpers/webrtc-helpers';
|
} from '../../helpers/webrtc-helpers';
|
||||||
import {
|
import {
|
||||||
authHeaders,
|
authHeaders,
|
||||||
readAuthTokenFromPage,
|
readAuthTokenFromPage,
|
||||||
registerTestUser
|
registerTestUser,
|
||||||
|
type AuthSession
|
||||||
} from '../../helpers/auth-api';
|
} from '../../helpers/auth-api';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||||
|
import { getMinimumConnectedPeerMeshCount, waitForConnectedRemotePeerMesh } from '../../helpers/signal-manager';
|
||||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
|
|
||||||
// ── Signal endpoint identifiers ──────────────────────────────────────
|
// ── Signal endpoint identifiers ──────────────────────────────────────
|
||||||
@@ -131,7 +134,8 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
await installTestServerEndpoints(client.context, groupEndpoints);
|
await installTestServerEndpoints(client.context, groupEndpoints);
|
||||||
await installDeterministicVoiceSettings(client.page);
|
await installDeterministicVoiceSettings(client.page);
|
||||||
await installWebRTCTracking(client.page);
|
await installWebRTCTracking(client.context);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
clients.push({ ...client, user });
|
clients.push({ ...client, user });
|
||||||
}
|
}
|
||||||
@@ -151,6 +155,12 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let secondaryRoomId = '';
|
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 ────────────────────────────────────────────
|
// ── Create rooms ────────────────────────────────────────────
|
||||||
await test.step('Create voice room on primary and chat room on secondary', async () => {
|
await test.step('Create voice room on primary and chat room on secondary', async () => {
|
||||||
@@ -192,6 +202,7 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
secondaryRoomId = secondaryRoom.id;
|
secondaryRoomId = secondaryRoom.id;
|
||||||
|
secondaryRoomOwner = secondarySession;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Create invite links ─────────────────────────────────────
|
// ── Create invite links ─────────────────────────────────────
|
||||||
@@ -221,17 +232,14 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
|
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
|
||||||
|
|
||||||
// Create invite for secondary room (chat) via API
|
// Create invite for secondary room (chat) via API using the API session
|
||||||
const secondaryToken = await readAuthTokenFromPage(clients[0].page, secondaryServer.url);
|
// that owns the room. The page-stored token for the secondary endpoint
|
||||||
|
// belongs to client 0's auto-provisioned identity, which is not the
|
||||||
if (!secondaryToken) {
|
// room owner and would be rejected with NOT_MEMBER.
|
||||||
throw new Error('Missing session token for secondary signal invite creation');
|
|
||||||
}
|
|
||||||
|
|
||||||
const secondaryInvite = await createInviteViaApi(
|
const secondaryInvite = await createInviteViaApi(
|
||||||
secondaryServer.url,
|
secondaryServer.url,
|
||||||
secondaryRoomId,
|
secondaryRoomId,
|
||||||
secondaryToken,
|
secondaryRoomOwner.token,
|
||||||
clients[0].user.displayName
|
clients[0].user.displayName
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -295,8 +303,11 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||||
|
await client.page.waitForTimeout(2_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await clients[0].page.waitForTimeout(10_000);
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
}
|
}
|
||||||
@@ -305,11 +316,11 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
// ── Audio mesh ──────────────────────────────────────────────
|
// ── Audio mesh ──────────────────────────────────────────────
|
||||||
await test.step('All users discover peers and audio flows pairwise', async () => {
|
await test.step('All users discover peers and audio flows pairwise', async () => {
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForPeerConnected(client.page, 45_000)
|
waitForPeerConnected(client.page, 90_000)
|
||||||
));
|
));
|
||||||
|
|
||||||
await Promise.all(clients.map((client) =>
|
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) =>
|
await Promise.all(clients.map((client) =>
|
||||||
@@ -319,7 +330,7 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
await clients[0].page.waitForTimeout(5_000);
|
await clients[0].page.waitForTimeout(5_000);
|
||||||
|
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -330,7 +341,6 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
await openVoiceWorkspace(client.page);
|
await openVoiceWorkspace(client.page);
|
||||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -367,18 +377,28 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
for (const client of stayers) {
|
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,
|
timeout: 10_000,
|
||||||
intervals: [500, 1_000]
|
intervals: [500, 1_000]
|
||||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
}).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check chatters still have voice peers even while viewing another room
|
// Check chatters still have voice peers even while viewing another room
|
||||||
for (const chatter of chatters) {
|
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,
|
timeout: 10_000,
|
||||||
intervals: [500, 1_000]
|
intervals: [500, 1_000]
|
||||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
}).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Date.now() < deadline) {
|
if (Date.now() < deadline) {
|
||||||
@@ -744,63 +764,6 @@ async function waitForLocalVoiceChannelConnection(page: Page, channelName: strin
|
|||||||
|
|
||||||
// ── Roster / state helpers ───────────────────────────────────────────
|
// ── 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(
|
async function waitForVoiceStateAcrossPages(
|
||||||
clients: readonly TestClient[],
|
clients: readonly TestClient[],
|
||||||
displayName: string,
|
displayName: string,
|
||||||
|
|||||||
@@ -6,14 +6,21 @@ import {
|
|||||||
dumpRtcDiagnostics,
|
dumpRtcDiagnostics,
|
||||||
getConnectedPeerCount,
|
getConnectedPeerCount,
|
||||||
installWebRTCTracking,
|
installWebRTCTracking,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
waitForAllPeerAudioFlow,
|
waitForAllPeerAudioFlow,
|
||||||
waitForAudioStatsPresent,
|
waitForAudioStatsPresent,
|
||||||
waitForConnectedPeerCount,
|
|
||||||
waitForPeerConnected
|
waitForPeerConnected
|
||||||
} from '../../helpers/webrtc-helpers';
|
} from '../../helpers/webrtc-helpers';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.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 PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
|
||||||
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
|
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
|
||||||
@@ -116,8 +123,11 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||||
|
await client.page.waitForTimeout(2_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await clients[0].page.waitForTimeout(10_000);
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
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 () => {
|
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)
|
// Wait for all clients to have at least one connected peer (fast)
|
||||||
await Promise.all(clients.map((client) =>
|
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
|
// Wait for all clients to have all 7 peers connected
|
||||||
await Promise.all(clients.map((client) =>
|
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
|
// 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
|
// Check bidirectional audio flow on each client
|
||||||
await Promise.all(clients.map((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 openVoiceWorkspace(client.page);
|
||||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
await waitForConnectedSignalManagerCount(client.page, 2);
|
await waitForConnectedSignalManagerCount(client.page, 2);
|
||||||
}
|
}
|
||||||
@@ -167,10 +176,15 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
for (const client of clients) {
|
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,
|
timeout: 10_000,
|
||||||
intervals: [500, 1_000]
|
intervals: [500, 1_000]
|
||||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
}).toBe(true);
|
||||||
|
|
||||||
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
|
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
@@ -292,7 +306,8 @@ async function createTrackedClients(
|
|||||||
|
|
||||||
await installTestServerEndpoints(client.context, endpoints);
|
await installTestServerEndpoints(client.context, endpoints);
|
||||||
await installDeterministicVoiceSettings(client.page);
|
await installDeterministicVoiceSettings(client.page);
|
||||||
await installWebRTCTracking(client.page);
|
await installWebRTCTracking(client.context);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
clients.push({
|
clients.push({
|
||||||
...client,
|
...client,
|
||||||
@@ -576,124 +591,6 @@ async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise
|
|||||||
}, channelName);
|
}, 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(
|
async function waitForVoiceStateAcrossPages(
|
||||||
clients: readonly TestClient[],
|
clients: readonly TestClient[],
|
||||||
displayName: string,
|
displayName: string,
|
||||||
|
|||||||
127
e2e/tests/voice/voice-mute-state-reset.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,10 @@ export interface AppMetricsProcessSnapshot {
|
|||||||
pid: number;
|
pid: number;
|
||||||
type: string;
|
type: string;
|
||||||
workingSetKb: number | null;
|
workingSetKb: number | null;
|
||||||
|
peakWorkingSetKb: number | null;
|
||||||
|
privateBytesKb: number | null;
|
||||||
|
creationTime: number | null;
|
||||||
|
cpuPercent: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppMetricsSnapshot {
|
export interface AppMetricsSnapshot {
|
||||||
@@ -17,7 +21,17 @@ export function collectAppMetricsSnapshot(): AppMetricsSnapshot {
|
|||||||
processes: app.getAppMetrics().map((metric) => ({
|
processes: app.getAppMetrics().map((metric) => ({
|
||||||
pid: metric.pid,
|
pid: metric.pid,
|
||||||
type: metric.type,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { isPerfDiagEnabled } from './diagnostics.flags';
|
|||||||
describe('isPerfDiagEnabled', () => {
|
describe('isPerfDiagEnabled', () => {
|
||||||
it('returns false when the flag is unset', () => {
|
it('returns false when the flag is unset', () => {
|
||||||
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||||
expect(isPerfDiagEnabled({}, true)).toBe(false);
|
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
||||||
@@ -17,11 +17,12 @@ describe('isPerfDiagEnabled', () => {
|
|||||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false in packaged builds unless force is set', () => {
|
it('returns true in packaged Electron builds without env flags', () => {
|
||||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false);
|
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||||
expect(isPerfDiagEnabled({
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '0' }, true)).toBe(true);
|
||||||
METOYOU_PERF_DIAG: '1',
|
});
|
||||||
METOYOU_PERF_DIAG_FORCE: '1'
|
|
||||||
}, true)).toBe(true);
|
it('returns false in development when the flag is unset', () => {
|
||||||
|
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,13 +17,9 @@ export function isPerfDiagEnabled(
|
|||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
isPackaged: boolean
|
isPackaged: boolean
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
|
if (isPackaged) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return isTruthyFlag(env[PERF_DIAG_ENV]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
ipcMain
|
ipcMain,
|
||||||
|
shell
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import { collectAppMetricsSnapshot } from '../app-metrics';
|
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 { sumWorkingSetKb } from './process-metrics.rules';
|
||||||
import { isPerfDiagEnabled } from './diagnostics.flags';
|
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||||
|
import { exceedsHighMemoryThreshold } from './high-memory-alert.rules';
|
||||||
|
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules';
|
||||||
|
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
|
||||||
|
import { collectSessionContext } from './session-context.collector';
|
||||||
|
import {
|
||||||
|
clearHighMemoryAlert,
|
||||||
|
readHighMemoryAlert,
|
||||||
|
writeHighMemoryAlert
|
||||||
|
} from './high-memory-alert.store';
|
||||||
import type { PerfDiagEntry } from './diagnostics.models';
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
import { PerfDiagWriter } from './diagnostics.writer';
|
import { PerfDiagWriter } from './diagnostics.writer';
|
||||||
|
|
||||||
@@ -15,6 +27,8 @@ let activeWriter: PerfDiagWriter | null = null;
|
|||||||
let processPollTimer: NodeJS.Timeout | null = null;
|
let processPollTimer: NodeJS.Timeout | null = null;
|
||||||
let diagnosticsEnabled = false;
|
let diagnosticsEnabled = false;
|
||||||
let ipcRegistered = false;
|
let ipcRegistered = false;
|
||||||
|
let highMemoryAlertTriggeredThisSession = false;
|
||||||
|
let sessionStartedAt = 0;
|
||||||
|
|
||||||
export function isPerfDiagActive(): boolean {
|
export function isPerfDiagActive(): boolean {
|
||||||
return diagnosticsEnabled;
|
return diagnosticsEnabled;
|
||||||
@@ -43,6 +57,37 @@ export function ensurePerfDiagIpcRegistered(): void {
|
|||||||
return false;
|
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('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 {
|
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
||||||
@@ -64,9 +109,13 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
|
|||||||
});
|
});
|
||||||
|
|
||||||
activeWriter = writer;
|
activeWriter = writer;
|
||||||
|
highMemoryAlertTriggeredThisSession = false;
|
||||||
|
sessionStartedAt = Date.now();
|
||||||
registerProcessCrashHandlers(writer);
|
registerProcessCrashHandlers(writer);
|
||||||
startProcessMetricsPolling(writer);
|
startProcessMetricsPolling(writer);
|
||||||
|
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
|
||||||
writer.append({
|
writer.append({
|
||||||
collectedAt: Date.now(),
|
collectedAt: Date.now(),
|
||||||
source: 'main',
|
source: 'main',
|
||||||
@@ -78,6 +127,18 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
writer.append({
|
||||||
|
collectedAt: Date.now(),
|
||||||
|
source: 'main',
|
||||||
|
type: 'environment',
|
||||||
|
payload: {
|
||||||
|
...collectSessionContext({
|
||||||
|
sessionStartedAt,
|
||||||
|
userDataPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return writer;
|
return writer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +256,8 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
|||||||
processes: metrics.processes
|
processes: metrics.processes
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void maybeTriggerHighMemoryAlert(writer, metrics, totalKb);
|
||||||
} catch {
|
} catch {
|
||||||
// Collector failures must never affect the app.
|
// Collector failures must never affect the app.
|
||||||
}
|
}
|
||||||
@@ -204,6 +267,64 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
|||||||
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function maybeTriggerHighMemoryAlert(
|
||||||
|
writer: PerfDiagWriter,
|
||||||
|
metrics: AppMetricsSnapshot,
|
||||||
|
totalWorkingSetKb: number | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (highMemoryAlertTriggeredThisSession || !exceedsHighMemoryThreshold(totalWorkingSetKb)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
highMemoryAlertTriggeredThisSession = true;
|
||||||
|
|
||||||
|
const detectedAt = Date.now();
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
const immediateRendererEntries = await collectImmediateRendererSamples(getMainWindow());
|
||||||
|
const environment = collectSessionContext({
|
||||||
|
sessionStartedAt,
|
||||||
|
userDataPath
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of immediateRendererEntries) {
|
||||||
|
writer.append(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.append({
|
||||||
|
collectedAt: detectedAt,
|
||||||
|
source: 'main',
|
||||||
|
type: 'environment',
|
||||||
|
payload: {
|
||||||
|
...environment
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
writer.append({
|
||||||
|
collectedAt: detectedAt,
|
||||||
|
source: 'main',
|
||||||
|
type: 'high-memory',
|
||||||
|
payload: buildHighMemoryDiagnosticPayload({
|
||||||
|
detectedAt,
|
||||||
|
totalWorkingSetKb: totalWorkingSetKb ?? 0,
|
||||||
|
metrics,
|
||||||
|
environment,
|
||||||
|
mainProcessMemory: process.memoryUsage(),
|
||||||
|
ringEntries: writer.bufferedEntries,
|
||||||
|
immediateRendererEntries,
|
||||||
|
sessionId: writer.sessionId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await writer.flushSnapshot('high-memory-threshold');
|
||||||
|
|
||||||
|
await writeHighMemoryAlert(userDataPath, {
|
||||||
|
logFilePath: writer.snapshotFilePath,
|
||||||
|
detectedAt,
|
||||||
|
peakWorkingSetKb: totalWorkingSetKb ?? 0,
|
||||||
|
sessionId: writer.sessionId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
||||||
return {
|
return {
|
||||||
collectedAt: Number(entry.collectedAt) || Date.now(),
|
collectedAt: Number(entry.collectedAt) || Date.now(),
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
|
|||||||
|
|
||||||
export type PerfDiagEntryType =
|
export type PerfDiagEntryType =
|
||||||
| 'session'
|
| 'session'
|
||||||
|
| 'environment'
|
||||||
| 'process'
|
| 'process'
|
||||||
| 'store'
|
| 'store'
|
||||||
| 'components'
|
| 'components'
|
||||||
| 'heap'
|
| 'heap'
|
||||||
|
| 'high-memory'
|
||||||
| 'crash'
|
| 'crash'
|
||||||
| 'unresponsive';
|
| 'unresponsive';
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
resolveDiagnosticsFilePath
|
resolveDiagnosticsFilePath
|
||||||
} from './diagnostics.rules';
|
} from './diagnostics.rules';
|
||||||
|
|
||||||
const DEFAULT_RING_CAPACITY = 120;
|
const DEFAULT_RING_CAPACITY = 300;
|
||||||
const FLUSH_DEBOUNCE_MS = 250;
|
const FLUSH_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
export interface PerfDiagWriterOptions {
|
export interface PerfDiagWriterOptions {
|
||||||
@@ -18,6 +18,7 @@ export interface PerfDiagWriterOptions {
|
|||||||
|
|
||||||
export class PerfDiagWriter {
|
export class PerfDiagWriter {
|
||||||
private readonly filePath: string;
|
private readonly filePath: string;
|
||||||
|
private readonly sessionIdValue: string;
|
||||||
private readonly ringCapacity: number;
|
private readonly ringCapacity: number;
|
||||||
private readonly pendingLines: string[] = [];
|
private readonly pendingLines: string[] = [];
|
||||||
private ring: PerfDiagEntry[] = [];
|
private ring: PerfDiagEntry[] = [];
|
||||||
@@ -26,10 +27,15 @@ export class PerfDiagWriter {
|
|||||||
private disabled = false;
|
private disabled = false;
|
||||||
|
|
||||||
constructor(options: PerfDiagWriterOptions) {
|
constructor(options: PerfDiagWriterOptions) {
|
||||||
|
this.sessionIdValue = options.sessionId;
|
||||||
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
||||||
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get sessionId(): string {
|
||||||
|
return this.sessionIdValue;
|
||||||
|
}
|
||||||
|
|
||||||
get snapshotFilePath(): string {
|
get snapshotFilePath(): string {
|
||||||
return this.filePath;
|
return this.filePath;
|
||||||
}
|
}
|
||||||
|
|||||||
27
electron/diagnostics/high-memory-alert.rules.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
11
electron/diagnostics/high-memory-alert.rules.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
64
electron/diagnostics/high-memory-alert.store.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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'
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
57
electron/diagnostics/high-memory-alert.store.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export interface HighMemoryAlertRecord {
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
} 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
201
electron/diagnostics/high-memory-snapshot.rules.spec.ts
Normal file
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
179
electron/diagnostics/high-memory-snapshot.rules.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
39
electron/diagnostics/immediate-renderer-samples.collector.ts
Normal file
@@ -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 ?? {}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,16 @@
|
|||||||
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
|
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 {
|
export {
|
||||||
attachRendererDiagnosticsHooks,
|
attachRendererDiagnosticsHooks,
|
||||||
ensurePerfDiagIpcRegistered,
|
ensurePerfDiagIpcRegistered,
|
||||||
|
|||||||
91
electron/diagnostics/session-context.collector.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -259,6 +259,14 @@ export interface ElectronAPI {
|
|||||||
type: string;
|
type: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
|
getPendingHighMemoryAlert: () => Promise<{
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
} | null>;
|
||||||
|
acknowledgeHighMemoryAlert: () => Promise<boolean>;
|
||||||
|
showLogFileInFolder: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
openCurrentDataFolder: () => Promise<boolean>;
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
exportUserData: () => Promise<ExportUserDataResult>;
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
@@ -400,6 +408,9 @@ const electronAPI: ElectronAPI = {
|
|||||||
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
||||||
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
|
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
|
||||||
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
|
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
|
||||||
|
getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'),
|
||||||
|
acknowledgeHighMemoryAlert: () => ipcRenderer.invoke('acknowledge-high-memory-alert'),
|
||||||
|
showLogFileInFolder: (filePath) => ipcRenderer.invoke('show-log-file-in-folder', filePath),
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
||||||
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { DESKTOP_APP_DISPLAY_NAME } from '../app/desktop-branding.rules';
|
import { DESKTOP_APP_DISPLAY_NAME } from '../app/desktop-branding.rules';
|
||||||
import { readDesktopSettings } from '../desktop-settings';
|
import { readDesktopSettings } from '../desktop-settings';
|
||||||
|
import { resolveDevelopmentClientUrl } from './dev-client-url.rules';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
@@ -277,11 +278,7 @@ export async function createWindow(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (process.env['NODE_ENV'] === 'development') {
|
if (process.env['NODE_ENV'] === 'development') {
|
||||||
const devUrl = process.env['SSL'] === 'true'
|
await mainWindow.loadURL(resolveDevelopmentClientUrl(process.env['SSL'] === 'true'));
|
||||||
? 'https://localhost:4200'
|
|
||||||
: 'http://localhost:4200';
|
|
||||||
|
|
||||||
await mainWindow.loadURL(devUrl);
|
|
||||||
|
|
||||||
if (process.env['DEBUG_DEVTOOLS'] === '1') {
|
if (process.env['DEBUG_DEVTOOLS'] === '1') {
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
|
|||||||
26
electron/window/dev-client-url.rules.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEV_CLIENT_HOST,
|
||||||
|
DEV_CLIENT_PORT,
|
||||||
|
resolveDevelopmentClientUrl
|
||||||
|
} from './dev-client-url.rules';
|
||||||
|
|
||||||
|
describe('dev-client-url.rules', () => {
|
||||||
|
it('targets loopback IPv4 so dev wait-on avoids stale localhost IPv6 listeners', () => {
|
||||||
|
expect(DEV_CLIENT_HOST).toBe('127.0.0.1');
|
||||||
|
expect(DEV_CLIENT_PORT).toBe(4200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the HTTP dev client URL', () => {
|
||||||
|
expect(resolveDevelopmentClientUrl(false)).toBe('http://127.0.0.1:4200');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the HTTPS dev client URL', () => {
|
||||||
|
expect(resolveDevelopmentClientUrl(true)).toBe('https://127.0.0.1:4200');
|
||||||
|
});
|
||||||
|
});
|
||||||
8
electron/window/dev-client-url.rules.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const DEV_CLIENT_HOST = '127.0.0.1';
|
||||||
|
export const DEV_CLIENT_PORT = 4200;
|
||||||
|
|
||||||
|
export function resolveDevelopmentClientUrl(sslEnabled: boolean): string {
|
||||||
|
const scheme = sslEnabled ? 'https' : 'http';
|
||||||
|
|
||||||
|
return `${scheme}://${DEV_CLIENT_HOST}:${DEV_CLIENT_PORT}`;
|
||||||
|
}
|
||||||
@@ -59,21 +59,8 @@ module.exports = tseslint.config(
|
|||||||
'ClassBody.body > PropertyDefinition[decorators.length > 0] > .key'
|
'ClassBody.body > PropertyDefinition[decorators.length > 0] > .key'
|
||||||
], SwitchCase:1 }],
|
], SwitchCase:1 }],
|
||||||
'@stylistic/ts/member-delimiter-style': ['error',{ multiline:{ delimiter:'semi', requireLast:true }, singleline:{ delimiter:'semi', requireLast:false } }],
|
'@stylistic/ts/member-delimiter-style': ['error',{ multiline:{ delimiter:'semi', requireLast:true }, singleline:{ delimiter:'semi', requireLast:false } }],
|
||||||
'@typescript-eslint/member-ordering': ['error',{ default:[
|
// Disabled: bulk member reordering breaks Angular inject()/field init order (TS2729).
|
||||||
'signature','call-signature',
|
'@typescript-eslint/member-ordering': 'off',
|
||||||
'public-static-field','protected-static-field','private-static-field','#private-static-field',
|
|
||||||
'public-decorated-field','protected-decorated-field','private-decorated-field',
|
|
||||||
'public-instance-field','protected-instance-field','private-instance-field','#private-instance-field',
|
|
||||||
'public-abstract-field','protected-abstract-field',
|
|
||||||
'public-field','protected-field','private-field','#private-field',
|
|
||||||
'static-field','instance-field','abstract-field','decorated-field','field','static-initialization',
|
|
||||||
'public-constructor','protected-constructor','private-constructor','constructor',
|
|
||||||
'public-static-method','protected-static-method','private-static-method','#private-static-method',
|
|
||||||
'public-decorated-method','protected-decorated-method','private-decorated-method',
|
|
||||||
'public-instance-method','protected-instance-method','private-instance-method','#private-instance-method',
|
|
||||||
'public-abstract-method','protected-abstract-method','public-method','protected-method','private-method','#private-method',
|
|
||||||
'static-method','instance-method','abstract-method','decorated-method','method'
|
|
||||||
] }],
|
|
||||||
'@typescript-eslint/no-empty-function': 'off',
|
'@typescript-eslint/no-empty-function': 'off',
|
||||||
'@typescript-eslint/no-empty-interface': 'error',
|
'@typescript-eslint/no-empty-interface': 'error',
|
||||||
'@typescript-eslint/no-explicit-any': 'error',
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
|||||||
BIN
images/icon-new-rounded.png
Normal file
|
After Width: | Height: | Size: 808 KiB |
547
package-lock.json
generated
@@ -93,10 +93,12 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-import-newlines": "^1.4.1",
|
"eslint-plugin-import-newlines": "^1.4.1",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
|
"fake-indexeddb": "^6.2.5",
|
||||||
"glob": "^10.5.0",
|
"glob": "^10.5.0",
|
||||||
"pkg": "^5.8.1",
|
"pkg": "^5.8.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"sharp": "^0.34.4",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.50.1",
|
"typescript-eslint": "8.50.1",
|
||||||
@@ -5601,6 +5603,496 @@
|
|||||||
"mlly": "^1.8.0"
|
"mlly": "^1.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@inquirer/ansi": {
|
"node_modules/@inquirer/ansi": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
|
||||||
@@ -20431,6 +20923,16 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/fake-indexeddb": {
|
||||||
|
"version": "6.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
|
||||||
|
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -31827,6 +32329,51 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.0.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.7.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.34.5",
|
||||||
|
"@img/sharp-darwin-x64": "0.34.5",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||||
|
"@img/sharp-linux-arm": "0.34.5",
|
||||||
|
"@img/sharp-linux-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linux-ppc64": "0.34.5",
|
||||||
|
"@img/sharp-linux-riscv64": "0.34.5",
|
||||||
|
"@img/sharp-linux-s390x": "0.34.5",
|
||||||
|
"@img/sharp-linux-x64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||||
|
"@img/sharp-wasm32": "0.34.5",
|
||||||
|
"@img/sharp-win32-arm64": "0.34.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.34.5",
|
||||||
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"server:start": "cd server && npm start",
|
"server:start": "cd server && npm start",
|
||||||
"server:dev": "cd server && npm run dev",
|
"server:dev": "cd server && npm run dev",
|
||||||
"electron": "npm run build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
|
"electron": "npm run build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
|
||||||
"electron:dev": "concurrently \"npm run start\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
"electron:dev": "concurrently \"npm run start\" \"wait-on http://127.0.0.1:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
||||||
"electron:full": "./dev.sh",
|
"electron:full": "./dev.sh",
|
||||||
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
||||||
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
"test:e2e:report": "node e2e/run-playwright.mjs show-report ../test-results/html-report",
|
"test:e2e:report": "node e2e/run-playwright.mjs show-report ../test-results/html-report",
|
||||||
"perf:diag:view": "node tools/perf-diag-viewer.js",
|
"perf:diag:view": "node tools/perf-diag-viewer.js",
|
||||||
"perf:diag:tail": "node tools/perf-diag-viewer.js --tail",
|
"perf:diag:tail": "node tools/perf-diag-viewer.js --tail",
|
||||||
|
"cap:assets:android": "node tools/generate-android-app-icons.mjs",
|
||||||
"cap:sync": "cd toju-app && npx cap sync",
|
"cap:sync": "cd toju-app && npx cap sync",
|
||||||
"cap:open:android": "node tools/cap-open-android.js",
|
"cap:open:android": "node tools/cap-open-android.js",
|
||||||
"cap:open:ios": "cd toju-app && npx cap open ios",
|
"cap:open:ios": "cd toju-app && npx cap open ios",
|
||||||
@@ -153,10 +154,12 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-import-newlines": "^1.4.1",
|
"eslint-plugin-import-newlines": "^1.4.1",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
|
"fake-indexeddb": "^6.2.5",
|
||||||
"glob": "^10.5.0",
|
"glob": "^10.5.0",
|
||||||
"pkg": "^5.8.1",
|
"pkg": "^5.8.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"sharp": "^0.34.4",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.50.1",
|
"typescript-eslint": "8.50.1",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ Owns the shared, internet-reachable runtime: HTTP routes for server directory /
|
|||||||
|
|
||||||
- Every database schema change ships as a TypeORM **migration**; the live database is never mutated outside the migration system.
|
- Every database schema change ships as a TypeORM **migration**; the live database is never mutated outside the migration system.
|
||||||
- WebSocket **Envelope** types are defined once in `src/websocket/types.ts` and **must** stay structurally compatible with `toju-app/src/app/shared-kernel/signaling-contracts.ts` — drift between the two is a wire-protocol break.
|
- WebSocket **Envelope** types are defined once in `src/websocket/types.ts` and **must** stay structurally compatible with `toju-app/src/app/shared-kernel/signaling-contracts.ts` — drift between the two is a wire-protocol break.
|
||||||
|
- WebSocket messages from a single connection are processed **strictly in arrival order** (`handleWebSocketMessage` chains them per connection id). Concurrent handling lets a `join_server` overtake a still-awaiting `identify` and silently drop room membership.
|
||||||
- User-supplied URLs are **never** fetched without going through `ssrf-guard.ts`.
|
- User-supplied URLs are **never** fetched without going through `ssrf-guard.ts`.
|
||||||
- Secrets (klipy API key, OAuth tokens, signing keys) live in `data/variables.json` or environment variables — never in code, never in logs.
|
- Secrets (klipy API key, OAuth tokens, signing keys) live in `data/variables.json` or environment variables — never in code, never in logs.
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ describe('broadcastToServer', () => {
|
|||||||
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||||
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||||
expect(connectedUsers.get('conn-a1')?.ws).toBeDefined();
|
expect(connectedUsers.get('conn-a1')?.ws).toBeDefined();
|
||||||
expect((connectedUsers.get('conn-a1')!.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
const connA1Passive = connectedUsers.get('conn-a1')?.ws as WebSocket & { sentMessages: string[] } | undefined;
|
||||||
|
|
||||||
|
expect(connA1Passive?.sentMessages).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes every connection for an identity when excludeIdentityOderId is set', () => {
|
it('excludes every connection for an identity when excludeIdentityOderId is set', () => {
|
||||||
|
|||||||
@@ -94,12 +94,13 @@ describe('server websocket handler - multi-client sessions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('relays voice_state to other connections for the same user', async () => {
|
it('relays voice_state to other connections for the same user', async () => {
|
||||||
const sender = createConnectedUser('conn-a1', {
|
createConnectedUser('conn-a1', {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
oderId: 'user-1',
|
oderId: 'user-1',
|
||||||
serverIds: new Set(['server-1']),
|
serverIds: new Set(['server-1']),
|
||||||
clientInstanceId: 'device-a'
|
clientInstanceId: 'device-a'
|
||||||
});
|
});
|
||||||
|
|
||||||
const passive = createConnectedUser('conn-a2', {
|
const passive = createConnectedUser('conn-a2', {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
oderId: 'user-1',
|
oderId: 'user-1',
|
||||||
@@ -129,7 +130,7 @@ describe('server websocket handler - multi-client sessions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('forwards RTC offers to the voice-active connection for the target user', async () => {
|
it('forwards RTC offers to the voice-active connection for the target user', async () => {
|
||||||
const sender = createConnectedUser('conn-sender', {
|
createConnectedUser('conn-sender', {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
oderId: 'user-2',
|
oderId: 'user-2',
|
||||||
serverIds: new Set(['server-1'])
|
serverIds: new Set(['server-1'])
|
||||||
@@ -228,6 +229,7 @@ describe('server websocket handler - multi-client sessions', () => {
|
|||||||
serverIds: new Set(['server-1']),
|
serverIds: new Set(['server-1']),
|
||||||
clientInstanceId: 'device-a'
|
clientInstanceId: 'device-a'
|
||||||
});
|
});
|
||||||
|
|
||||||
const receiver = createConnectedUser('conn-a2', {
|
const receiver = createConnectedUser('conn-a2', {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
oderId: 'user-1',
|
oderId: 'user-1',
|
||||||
|
|||||||
146
server/src/websocket/handler-ordering.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
beforeEach,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import { connectedUsers } from './state';
|
||||||
|
import { ConnectedUser } from './types';
|
||||||
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
|
||||||
|
vi.mock('../services/server-access.service', () => ({
|
||||||
|
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })),
|
||||||
|
findServerMembership: vi.fn(async () => ({ id: 'membership-1' })),
|
||||||
|
usersShareServerMembership: vi.fn(async () => false)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/session-auth.service', () => ({
|
||||||
|
// Resolve on a macrotask so an unserialized handler would interleave the
|
||||||
|
// next message before identify completes - mirrors a real DB lookup.
|
||||||
|
consumeSessionToken: vi.fn(async (token: string) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
if (token !== 'valid-token') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
createdAt: Date.now()
|
||||||
|
},
|
||||||
|
issuedAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 60_000
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/plugin-support.service', () => ({
|
||||||
|
getPluginRequirementsSnapshot: vi.fn(async () => ({ plugins: [] })),
|
||||||
|
PluginSupportError: class PluginSupportError extends Error {
|
||||||
|
code = 'TEST';
|
||||||
|
},
|
||||||
|
validatePluginEventEnvelope: vi.fn(async () => undefined)
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createMockWs(): WebSocket & { sentMessages: string[] } {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const ws = {
|
||||||
|
readyState: WebSocket.OPEN,
|
||||||
|
send: (data: string) => { sent.push(data); },
|
||||||
|
close: () => {},
|
||||||
|
sentMessages: sent
|
||||||
|
} as unknown as WebSocket & { sentMessages: string[] };
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConnectedUser(connectionId: string): ConnectedUser {
|
||||||
|
const ws = createMockWs();
|
||||||
|
const user: ConnectedUser = {
|
||||||
|
oderId: connectionId,
|
||||||
|
ws,
|
||||||
|
authenticated: false,
|
||||||
|
serverIds: new Set(),
|
||||||
|
displayName: 'Test User',
|
||||||
|
lastPong: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSentMessages(user: ConnectedUser | undefined): { type: string }[] {
|
||||||
|
const sentMessages = (user?.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||||
|
|
||||||
|
return sentMessages.map((raw) => JSON.parse(raw) as { type: string });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('server websocket handler - per-connection message ordering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes join_server after a still-running identify instead of dropping it', async () => {
|
||||||
|
createConnectedUser('conn-1');
|
||||||
|
|
||||||
|
// Both messages arrive in the same tick (one TCP segment); the handler
|
||||||
|
// must not evaluate join_server while identify is still awaiting auth.
|
||||||
|
const identifyPromise = handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'identify',
|
||||||
|
token: 'valid-token',
|
||||||
|
oderId: 'user-1',
|
||||||
|
displayName: 'Alice'
|
||||||
|
});
|
||||||
|
const joinPromise = handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'join_server',
|
||||||
|
serverId: 'server-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([identifyPromise, joinPromise]);
|
||||||
|
|
||||||
|
const user = connectedUsers.get('conn-1');
|
||||||
|
|
||||||
|
expect(user?.authenticated).toBe(true);
|
||||||
|
expect(user?.serverIds.has('server-1')).toBe(true);
|
||||||
|
|
||||||
|
const authRequired = getSentMessages(user).find((message) => message.type === 'auth_required');
|
||||||
|
|
||||||
|
expect(authRequired).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps processing queued messages after a handler error', async () => {
|
||||||
|
createConnectedUser('conn-1');
|
||||||
|
|
||||||
|
const badIdentify = handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'identify',
|
||||||
|
token: 'valid-token',
|
||||||
|
oderId: 'user-1',
|
||||||
|
displayName: 'Alice'
|
||||||
|
});
|
||||||
|
// Unknown types are logged and ignored - must not wedge the queue.
|
||||||
|
const unknown = handleWebSocketMessage('conn-1', { type: 'not-a-real-type' });
|
||||||
|
const join = handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'join_server',
|
||||||
|
serverId: 'server-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
badIdentify,
|
||||||
|
unknown,
|
||||||
|
join
|
||||||
|
]);
|
||||||
|
|
||||||
|
const user = connectedUsers.get('conn-1');
|
||||||
|
|
||||||
|
expect(user?.serverIds.has('server-1')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
105
server/src/websocket/handler-voice-disconnect.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import { connectedUsers } from './state';
|
||||||
|
import { ConnectedUser } from './types';
|
||||||
|
import { finalizeVoiceDisconnectForConnection } from './handler';
|
||||||
|
|
||||||
|
function createMockWs(): WebSocket & { sentMessages: string[] } {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const ws = {
|
||||||
|
readyState: WebSocket.OPEN,
|
||||||
|
send: (data: string) => { sent.push(data); },
|
||||||
|
close: () => {},
|
||||||
|
terminate: () => {},
|
||||||
|
sentMessages: sent
|
||||||
|
} as unknown as WebSocket & { sentMessages: string[] };
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConnectedUser(
|
||||||
|
connectionId: string,
|
||||||
|
overrides: Partial<ConnectedUser> = {}
|
||||||
|
): ConnectedUser {
|
||||||
|
const user: ConnectedUser = {
|
||||||
|
oderId: 'user-1',
|
||||||
|
ws: createMockWs(),
|
||||||
|
authenticated: true,
|
||||||
|
serverIds: new Set(['server-1']),
|
||||||
|
displayName: 'Alice',
|
||||||
|
lastPong: Date.now(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSentMessages(user: ConnectedUser): string[] {
|
||||||
|
return (user.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('finalizeVoiceDisconnectForConnection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcasts a cleared voice_state when a voice-active connection is removed', () => {
|
||||||
|
createConnectedUser('conn-voice', {
|
||||||
|
voiceActive: true,
|
||||||
|
voiceStateSnapshot: {
|
||||||
|
type: 'voice_state',
|
||||||
|
serverId: 'server-1',
|
||||||
|
voiceState: {
|
||||||
|
isConnected: true,
|
||||||
|
isMuted: true,
|
||||||
|
isDeafened: false,
|
||||||
|
roomId: 'voice-1',
|
||||||
|
serverId: 'server-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = createConnectedUser('conn-observer', { oderId: 'user-2' });
|
||||||
|
|
||||||
|
getSentMessages(observer).length = 0;
|
||||||
|
|
||||||
|
finalizeVoiceDisconnectForConnection('conn-voice');
|
||||||
|
|
||||||
|
const messages = getSentMessages(observer).map((raw) => JSON.parse(raw) as {
|
||||||
|
type: string;
|
||||||
|
voiceState?: { isConnected?: boolean; isMuted?: boolean; isDeafened?: boolean };
|
||||||
|
});
|
||||||
|
const voiceState = messages.find((message) => message.type === 'voice_state');
|
||||||
|
|
||||||
|
expect(voiceState).toMatchObject({
|
||||||
|
type: 'voice_state',
|
||||||
|
voiceState: {
|
||||||
|
isConnected: false,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(connectedUsers.get('conn-voice')?.voiceActive).toBe(false);
|
||||||
|
expect(connectedUsers.get('conn-voice')?.voiceStateSnapshot).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the connection was not voice-active', () => {
|
||||||
|
const observer = createConnectedUser('conn-observer', { oderId: 'user-2' });
|
||||||
|
|
||||||
|
createConnectedUser('conn-idle');
|
||||||
|
|
||||||
|
getSentMessages(observer).length = 0;
|
||||||
|
|
||||||
|
finalizeVoiceDisconnectForConnection('conn-idle');
|
||||||
|
|
||||||
|
expect(getSentMessages(observer)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -134,6 +134,59 @@ function clearVoiceActiveForOderId(oderId: string, exceptConnectionId?: string):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readVoiceStateServerId(snapshot: Record<string, unknown> | undefined): string | undefined {
|
||||||
|
if (!snapshot) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedVoiceState = snapshot['voiceState'];
|
||||||
|
|
||||||
|
if (nestedVoiceState && typeof nestedVoiceState === 'object') {
|
||||||
|
const nestedServerId = readMessageId((nestedVoiceState as { serverId?: unknown }).serverId);
|
||||||
|
|
||||||
|
if (nestedServerId) {
|
||||||
|
return nestedServerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return readMessageId(snapshot['serverId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast a cleared voice_state when a voice-active socket disappears without a graceful leave. */
|
||||||
|
export function finalizeVoiceDisconnectForConnection(connectionId: string): void {
|
||||||
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
|
if (!user?.authenticated || (!user.voiceActive && !user.voiceStateSnapshot)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverId = readVoiceStateServerId(user.voiceStateSnapshot) ?? user.viewedServerId;
|
||||||
|
|
||||||
|
if (serverId && user.serverIds.has(serverId)) {
|
||||||
|
broadcastToServer(
|
||||||
|
serverId,
|
||||||
|
{
|
||||||
|
type: 'voice_state',
|
||||||
|
serverId,
|
||||||
|
oderId: user.oderId,
|
||||||
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
|
voiceState: {
|
||||||
|
isConnected: false,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
isSpeaking: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ excludeConnectionId: connectionId }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.voiceActive = false;
|
||||||
|
user.voiceStateSnapshot = undefined;
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
clearVoiceActiveForOderId(user.oderId, connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
|
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
|
||||||
user.ws.send(JSON.stringify({
|
user.ws.send(JSON.stringify({
|
||||||
type: 'voice_state',
|
type: 'voice_state',
|
||||||
@@ -705,7 +758,32 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage, connec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
/**
|
||||||
|
* Tail of the in-flight message chain per connection.
|
||||||
|
*
|
||||||
|
* Messages from one client can arrive in the same tick (one TCP segment), but
|
||||||
|
* handlers like identify await async work. Without serialization a
|
||||||
|
* join_server can be evaluated while identify is still pending, get rejected
|
||||||
|
* as unauthenticated, and silently lose the room membership.
|
||||||
|
*/
|
||||||
|
const connectionMessageChains = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
export function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||||
|
const prior = connectionMessageChains.get(connectionId) ?? Promise.resolve();
|
||||||
|
const current = prior.then(() => processWebSocketMessage(connectionId, message));
|
||||||
|
const tail = current.catch(() => undefined);
|
||||||
|
|
||||||
|
connectionMessageChains.set(connectionId, tail);
|
||||||
|
void tail.then(() => {
|
||||||
|
if (connectionMessageChains.get(connectionId) === tail) {
|
||||||
|
connectionMessageChains.delete(connectionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||||
const user = connectedUsers.get(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
if (!user)
|
if (!user)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
getServerIdsForOderId,
|
getServerIdsForOderId,
|
||||||
isOderIdConnectedToServer
|
isOderIdConnectedToServer
|
||||||
} from './broadcast';
|
} from './broadcast';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage, finalizeVoiceDisconnectForConnection } from './handler';
|
||||||
|
|
||||||
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
|
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ function removeDeadConnection(connectionId: string): void {
|
|||||||
if (user) {
|
if (user) {
|
||||||
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||||
|
|
||||||
|
finalizeVoiceDisconnectForConnection(connectionId);
|
||||||
|
|
||||||
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||||
|
|
||||||
user.serverIds.forEach((sid) => {
|
user.serverIds.forEach((sid) => {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 172 KiB |
@@ -1,34 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportHeight="108"
|
|
||||||
android:viewportWidth="108">
|
|
||||||
<path
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeWidth="1">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:endX="78.5885"
|
|
||||||
android:endY="90.9159"
|
|
||||||
android:startX="48.7653"
|
|
||||||
android:startY="61.0927"
|
|
||||||
android:type="linear">
|
|
||||||
<item
|
|
||||||
android:color="#44000000"
|
|
||||||
android:offset="0.0" />
|
|
||||||
<item
|
|
||||||
android:color="#00000000"
|
|
||||||
android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:fillType="nonZero"
|
|
||||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeWidth="1" />
|
|
||||||
</vector>
|
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#FFFFFF</color>
|
<color name="ic_launcher_background">#4A217A</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -2,21 +2,31 @@
|
|||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<!-- Customize your theme here. -->
|
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
<item name="colorAccent">@color/colorAccent</item>
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@null</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
<item name="android:background">@drawable/splash</item>
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -15,6 +15,16 @@
|
|||||||
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
||||||
"updateSettings": "Update settings",
|
"updateSettings": "Update settings",
|
||||||
"restartNow": "Restart now"
|
"restartNow": "Restart now"
|
||||||
|
},
|
||||||
|
"highMemoryAlert": {
|
||||||
|
"badge": "High memory usage",
|
||||||
|
"title": "The app used {{usageGb}} GB of RAM last session",
|
||||||
|
"message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.",
|
||||||
|
"openLog": "Open log file",
|
||||||
|
"showInFolder": "Show in folder",
|
||||||
|
"copyPath": "Copy path",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"dismissAriaLabel": "Dismiss high memory alert"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,12 +262,14 @@
|
|||||||
"localData": {
|
"localData": {
|
||||||
"title": "Local data",
|
"title": "Local data",
|
||||||
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
||||||
|
"descriptionMobile": "Review and erase the private app storage that holds local messages, rooms, attachments, and saved settings on this device.",
|
||||||
"restartApp": "Restart app"
|
"restartApp": "Restart app"
|
||||||
},
|
},
|
||||||
"desktopOnly": "Data management is only available in the packaged Electron desktop app.",
|
"desktopOnly": "Data management is only available in the desktop app or native mobile app.",
|
||||||
"currentFolder": {
|
"currentFolder": {
|
||||||
"title": "Current data folder",
|
"title": "Current data folder",
|
||||||
"resolving": "Resolving data folder..."
|
"resolving": "Resolving data folder...",
|
||||||
|
"descriptionMobile": "Files are stored in the app's private data directory on this device."
|
||||||
},
|
},
|
||||||
"openFolder": "Open folder",
|
"openFolder": "Open folder",
|
||||||
"opening": "Opening...",
|
"opening": "Opening...",
|
||||||
@@ -287,6 +289,7 @@
|
|||||||
"erase": {
|
"erase": {
|
||||||
"title": "Erase user data",
|
"title": "Erase user data",
|
||||||
"description": "Remove local app data from this device and recreate an empty database.",
|
"description": "Remove local app data from this device and recreate an empty database.",
|
||||||
|
"descriptionMobile": "Remove local messages, rooms, attachments, and saved app data from this device.",
|
||||||
"button": "Erase user data",
|
"button": "Erase user data",
|
||||||
"erasing": "Erasing...",
|
"erasing": "Erasing...",
|
||||||
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
||||||
@@ -301,6 +304,7 @@
|
|||||||
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
||||||
"imported": "Imported data.",
|
"imported": "Imported data.",
|
||||||
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
||||||
|
"erasedMobile": "Local data erased. You have been signed out.",
|
||||||
"operationFailed": "Data operation failed."
|
"operationFailed": "Data operation failed."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
||||||
"updateSettings": "Update settings",
|
"updateSettings": "Update settings",
|
||||||
"restartNow": "Restart now"
|
"restartNow": "Restart now"
|
||||||
|
},
|
||||||
|
"highMemoryAlert": {
|
||||||
|
"badge": "High memory usage",
|
||||||
|
"title": "The app used {{usageGb}} GB of RAM last session",
|
||||||
|
"message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.",
|
||||||
|
"openLog": "Open log file",
|
||||||
|
"showInFolder": "Show in folder",
|
||||||
|
"copyPath": "Copy path",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"dismissAriaLabel": "Dismiss high memory alert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"attachment": {
|
"attachment": {
|
||||||
@@ -1317,12 +1327,14 @@
|
|||||||
"localData": {
|
"localData": {
|
||||||
"title": "Local data",
|
"title": "Local data",
|
||||||
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
||||||
|
"descriptionMobile": "Review and erase the private app storage that holds local messages, rooms, attachments, and saved settings on this device.",
|
||||||
"restartApp": "Restart app"
|
"restartApp": "Restart app"
|
||||||
},
|
},
|
||||||
"desktopOnly": "Data management is only available in the packaged Electron desktop app.",
|
"desktopOnly": "Data management is only available in the desktop app or native mobile app.",
|
||||||
"currentFolder": {
|
"currentFolder": {
|
||||||
"title": "Current data folder",
|
"title": "Current data folder",
|
||||||
"resolving": "Resolving data folder..."
|
"resolving": "Resolving data folder...",
|
||||||
|
"descriptionMobile": "Files are stored in the app's private data directory on this device."
|
||||||
},
|
},
|
||||||
"openFolder": "Open folder",
|
"openFolder": "Open folder",
|
||||||
"opening": "Opening...",
|
"opening": "Opening...",
|
||||||
@@ -1342,6 +1354,7 @@
|
|||||||
"erase": {
|
"erase": {
|
||||||
"title": "Erase user data",
|
"title": "Erase user data",
|
||||||
"description": "Remove local app data from this device and recreate an empty database.",
|
"description": "Remove local app data from this device and recreate an empty database.",
|
||||||
|
"descriptionMobile": "Remove local messages, rooms, attachments, and saved app data from this device.",
|
||||||
"button": "Erase user data",
|
"button": "Erase user data",
|
||||||
"erasing": "Erasing...",
|
"erasing": "Erasing...",
|
||||||
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
||||||
@@ -1356,6 +1369,7 @@
|
|||||||
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
||||||
"imported": "Imported data.",
|
"imported": "Imported data.",
|
||||||
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
||||||
|
"erasedMobile": "Local data erased. You have been signed out.",
|
||||||
"operationFailed": "Data operation failed."
|
"operationFailed": "Data operation failed."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<div
|
<div
|
||||||
appThemeNode="appRoot"
|
appThemeNode="appRoot"
|
||||||
class="workspace-bright-theme relative h-full overflow-hidden bg-background text-foreground"
|
class="workspace-bright-theme relative h-full overflow-hidden bg-background text-foreground"
|
||||||
|
[class.metoyou-safe-area-shell]="isMobile()"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="h-full min-h-0 min-w-0 overflow-hidden"
|
class="h-full min-h-0 min-w-0 overflow-hidden"
|
||||||
@@ -166,6 +167,7 @@
|
|||||||
<app-incoming-call-modal />
|
<app-incoming-call-modal />
|
||||||
<app-screen-share-source-picker />
|
<app-screen-share-source-picker />
|
||||||
<app-native-context-menu />
|
<app-native-context-menu />
|
||||||
|
<app-high-memory-alert-modal />
|
||||||
<app-debug-console [showLauncher]="false" />
|
<app-debug-console [showLauncher]="false" />
|
||||||
<app-theme-picker-overlay />
|
<app-theme-picker-overlay />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
loadLastViewedChatFromStorage
|
loadLastViewedChatFromStorage
|
||||||
} from './infrastructure/persistence';
|
} from './infrastructure/persistence';
|
||||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
||||||
|
import { DesktopHighMemoryAlertService } from './core/services/desktop-high-memory-alert.service';
|
||||||
import { ServerDirectoryFacade } from './domains/server-directory';
|
import { ServerDirectoryFacade } from './domains/server-directory';
|
||||||
import { NotificationsFacade } from './domains/notifications';
|
import { NotificationsFacade } from './domains/notifications';
|
||||||
import { TimeSyncService } from './core/services/time-sync.service';
|
import { TimeSyncService } from './core/services/time-sync.service';
|
||||||
@@ -53,12 +54,13 @@ import { SettingsModalComponent } from './features/settings/settings-modal/setti
|
|||||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||||
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
|
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
|
||||||
|
import { HighMemoryAlertModalComponent } from './features/shell/high-memory-alert-modal/high-memory-alert-modal.component';
|
||||||
import { UsersActions } from './store/users/users.actions';
|
import { UsersActions } from './store/users/users.actions';
|
||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
import { ROOM_URL_PATTERN } from './core/constants';
|
import { ROOM_URL_PATTERN } from './core/constants';
|
||||||
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
|
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
|
||||||
import { buildLoginReturnQueryParams } from './domains/authentication/domain/logic/auth-navigation.rules';
|
import { resolveUnauthenticatedStartupRedirect } from './domains/authentication/domain/logic/auth-navigation.rules';
|
||||||
import { runWhenIdle } from './shared/rxjs';
|
import { runWhenIdle } from './shared/rxjs';
|
||||||
import {
|
import {
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
@@ -81,6 +83,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from './core/i18n';
|
|||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareSourcePickerComponent,
|
ScreenShareSourcePickerComponent,
|
||||||
NativeContextMenuComponent,
|
NativeContextMenuComponent,
|
||||||
|
HighMemoryAlertModalComponent,
|
||||||
PrivateCallComponent,
|
PrivateCallComponent,
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
ThemePickerOverlayComponent,
|
ThemePickerOverlayComponent,
|
||||||
@@ -103,6 +106,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
desktopUpdates = inject(DesktopAppUpdateService);
|
desktopUpdates = inject(DesktopAppUpdateService);
|
||||||
desktopUpdateState = this.desktopUpdates.state;
|
desktopUpdateState = this.desktopUpdates.state;
|
||||||
|
desktopHighMemoryAlert = inject(DesktopHighMemoryAlertService);
|
||||||
readonly databaseService = inject(DatabaseService);
|
readonly databaseService = inject(DatabaseService);
|
||||||
readonly router = inject(Router);
|
readonly router = inject(Router);
|
||||||
readonly servers = inject(ServerDirectoryFacade);
|
readonly servers = inject(ServerDirectoryFacade);
|
||||||
@@ -288,6 +292,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
// - desktop deep-link bridge (only relevant after first paint)
|
// - desktop deep-link bridge (only relevant after first paint)
|
||||||
// - background presence + game activity loops
|
// - background presence + game activity loops
|
||||||
void this.desktopUpdates.initialize();
|
void this.desktopUpdates.initialize();
|
||||||
|
void this.desktopHighMemoryAlert.initialize();
|
||||||
void this.kickOffBackgroundBootstrap();
|
void this.kickOffBackgroundBootstrap();
|
||||||
|
|
||||||
// The only thing we genuinely must await before deciding which route
|
// The only thing we genuinely must await before deciding which route
|
||||||
@@ -308,22 +313,17 @@ export class App implements OnInit, OnDestroy {
|
|||||||
const currentUrl = this.getCurrentRouteUrl();
|
const currentUrl = this.getCurrentRouteUrl();
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
if (!this.isPublicRoute(currentUrl)) {
|
// Signed-out visitors are greeted with the login screen on every platform.
|
||||||
// On mobile, new/unauthenticated visitors landing on the app root or
|
// Mobile is intentionally not special-cased here: previously mobile users
|
||||||
// /dashboard should stay on /dashboard (which already exposes a login
|
// landing on the root or /dashboard were left on a logged-out dashboard,
|
||||||
// CTA). The login form has no mobile chrome / back button, so dropping
|
// which read as "no login screen on startup".
|
||||||
// new users straight onto it leaves them with no way to navigate away.
|
const startupRedirect = resolveUnauthenticatedStartupRedirect(currentUrl);
|
||||||
const currentPath = this.getRoutePath(currentUrl);
|
|
||||||
const isSearchLanding = currentPath === '/' || currentPath === '/dashboard';
|
|
||||||
|
|
||||||
if (this.isMobile() && isSearchLanding) {
|
if (startupRedirect) {
|
||||||
this.router.navigate(['/dashboard'], { replaceUrl: true }).catch(() => {});
|
this.router.navigate([startupRedirect.path], {
|
||||||
} else {
|
queryParams: startupRedirect.queryParams
|
||||||
this.router.navigate(['/login'], {
|
|
||||||
queryParams: buildLoginReturnQueryParams(currentUrl)
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||||
this.store.dispatch(RoomsActions.loadRooms());
|
this.store.dispatch(RoomsActions.loadRooms());
|
||||||
@@ -483,14 +483,6 @@ export class App implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private isPublicRoute(url: string): boolean {
|
|
||||||
const path = this.getRoutePath(url);
|
|
||||||
|
|
||||||
return path === '/login' ||
|
|
||||||
path === '/register' ||
|
|
||||||
path.startsWith('/invite/');
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCurrentRouteUrl(): string {
|
private getCurrentRouteUrl(): string {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return this.router.url;
|
return this.router.url;
|
||||||
|
|||||||
@@ -251,6 +251,13 @@ export interface ElectronPerfDiagEntry {
|
|||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ElectronHighMemoryAlertRecord {
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronApi {
|
export interface ElectronApi {
|
||||||
linuxDisplayServer: string;
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
@@ -272,6 +279,9 @@ export interface ElectronApi {
|
|||||||
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
||||||
isPerfDiagEnabled?: () => Promise<boolean>;
|
isPerfDiagEnabled?: () => Promise<boolean>;
|
||||||
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
|
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
|
||||||
|
getPendingHighMemoryAlert?: () => Promise<ElectronHighMemoryAlertRecord | null>;
|
||||||
|
acknowledgeHighMemoryAlert?: () => Promise<boolean>;
|
||||||
|
showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
openCurrentDataFolder: () => Promise<boolean>;
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
exportUserData: () => Promise<ExportUserDataResult>;
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
formatAppRamLabel,
|
formatAppRamLabel,
|
||||||
|
formatKilobytesAsGigabytes,
|
||||||
formatKilobytesAsMegabytes,
|
formatKilobytesAsMegabytes,
|
||||||
sumWorkingSetKb
|
sumWorkingSetKb
|
||||||
} from './electron-app-metrics.rules';
|
} from './electron-app-metrics.rules';
|
||||||
@@ -38,6 +39,13 @@ describe('sumWorkingSetKb', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatKilobytesAsGigabytes', () => {
|
||||||
|
it('formats totals in gigabytes with two decimals', () => {
|
||||||
|
expect(formatKilobytesAsGigabytes(1536 * 1024)).toBe('1.50');
|
||||||
|
expect(formatKilobytesAsGigabytes(2 * 1024 * 1024)).toBe('2.00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('formatKilobytesAsMegabytes', () => {
|
describe('formatKilobytesAsMegabytes', () => {
|
||||||
it('rounds large values to whole megabytes', () => {
|
it('rounds large values to whole megabytes', () => {
|
||||||
expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB');
|
expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB');
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export function formatKilobytesAsMegabytes(kilobytes: number): string {
|
|||||||
return `${megabytes.toFixed(2)} MB`;
|
return `${megabytes.toFixed(2)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatKilobytesAsGigabytes(kilobytes: number): string {
|
||||||
|
return (kilobytes / (1024 * 1024)).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null {
|
export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null {
|
||||||
const totalKb = sumWorkingSetKb(snapshot.processes);
|
const totalKb = sumWorkingSetKb(snapshot.processes);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
import { PlatformService } from '../platform';
|
||||||
|
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
|
||||||
|
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
|
||||||
|
import { formatKilobytesAsGigabytes } from '../platform/electron/electron-app-metrics.rules';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class DesktopHighMemoryAlertService {
|
||||||
|
private readonly platform = inject(PlatformService);
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
readonly pendingAlert = signal<ElectronHighMemoryAlertRecord | null>(null);
|
||||||
|
|
||||||
|
readonly peakUsageGb = computed(() => {
|
||||||
|
const alert = this.pendingAlert();
|
||||||
|
|
||||||
|
return alert ? formatKilobytesAsGigabytes(alert.peakWorkingSetKb) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (!this.platform.isElectron) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!api?.getPendingHighMemoryAlert) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alert = await api.getPendingHighMemoryAlert();
|
||||||
|
|
||||||
|
if (alert) {
|
||||||
|
this.pendingAlert.set(alert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async dismiss(): Promise<void> {
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
await api?.acknowledgeHighMemoryAlert?.();
|
||||||
|
this.pendingAlert.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async openLogFile(): Promise<void> {
|
||||||
|
const alert = this.pendingAlert();
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!alert?.logFilePath || !api?.openFilePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.openFilePath(alert.logFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async showLogFileInFolder(): Promise<void> {
|
||||||
|
const alert = this.pendingAlert();
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!alert?.logFilePath || !api?.showLogFileInFolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.showLogFileInFolder(alert.logFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyLogPath(): Promise<void> {
|
||||||
|
const alert = this.pendingAlert();
|
||||||
|
|
||||||
|
if (!alert?.logFilePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(alert.logFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,6 +101,14 @@ sequenceDiagram
|
|||||||
Note over R: shouldPersistDownloadedAttachment? Save to disk
|
Note over R: shouldPersistDownloadedAttachment? Save to disk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Transfer integrity invariants
|
||||||
|
|
||||||
|
Concurrent triggers (file-announce, message sync, peer connect) can race to request the same file, and a sender can receive duplicate `file-request`s for the same attachment. The transfer service enforces these invariants so duplicate streams can never corrupt a download (regression: receivers used to finalize after only the first chunks):
|
||||||
|
|
||||||
|
- **Requester:** `requestFromAnyPeer` marks the request pending *synchronously* before any async work, so the manager's `hasPendingRequest` gate closes the double-request race window.
|
||||||
|
- **Sender:** `handleFileRequest` / `fulfillRequestWithFile` track active outbound streams per `(messageId, fileId, peerId)` and ignore duplicate requests while a stream is in flight. A fresh `file-request` clears any earlier `file-cancel` marker from that peer.
|
||||||
|
- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped.
|
||||||
|
|
||||||
### Failure handling
|
### Failure handling
|
||||||
|
|
||||||
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
|
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
|
||||||
@@ -139,8 +147,28 @@ Browser chat views render audio/video larger than 50 MB with the same generic fi
|
|||||||
|
|
||||||
An optional experimental VLC.js adapter can be enabled from General settings. When enabled, unsupported downloaded audio/video files show a manual Play action that lazy-loads `/vlcjs/metoyou-vlc-player.js`. The runtime is intentionally isolated in the experimental media domain and is not part of the default attachment path.
|
An optional experimental VLC.js adapter can be enabled from General settings. When enabled, unsupported downloaded audio/video files show a manual Play action that lazy-loads `/vlcjs/metoyou-vlc-player.js`. The runtime is intentionally isolated in the experimental media domain and is not part of the default attachment path.
|
||||||
|
|
||||||
|
## Ownership and the "Shared from your device" label
|
||||||
|
|
||||||
|
`uploaderPeerId` is the **user** id of whoever uploaded the file, not a per-device id. It is intentionally stable across a user's devices so an uploader can recognise their own attachments after sync. Because of that, "did *this* device upload it?" and "does *this* device hold the bytes?" are two different questions, and the UI must key the *sharing* affordance off the latter.
|
||||||
|
|
||||||
|
`attachment-sharing.rules.ts` makes this explicit:
|
||||||
|
|
||||||
|
- `isUploaderUser(attachment, currentUserId)` — the current user is the uploader (same user, any device).
|
||||||
|
- `deviceHasLocalCopy(attachment)` — this device physically holds the bytes (`available` + a blob `objectUrl`, or a non-empty `savedPath`/`filePath`). Synced metadata alone does not count, because P2P/account sync strips local paths.
|
||||||
|
- `isSharingFromThisDevice(attachment, currentUserId)` — `isUploaderUser && deviceHasLocalCopy`. Only this returns the "Shared from your device" state.
|
||||||
|
|
||||||
|
The chat message item renders "Shared from your device" (and hides the request/download affordance) **only** when `isSharingFromThisDevice` is true. A second device of the same user that merely synced the message metadata is the uploader-user but holds no local copy, so it falls back to the normal recipient flow (request/download) instead of falsely claiming ownership and blocking the file (regression: the old check used `uploaderPeerId === currentUserId` and so claimed ownership on every device of the uploader). The transfer service uses the same rule to decide whether a no-peers failure should read "your original upload is missing" (sharing device) or "no connected peers" (any other device).
|
||||||
|
|
||||||
## Persistence
|
## Persistence
|
||||||
|
|
||||||
|
Attachment file persistence is platform-agnostic. `AttachmentStorageService` owns the `server/<room>/<bucket>` and `direct-messages/...` path layout and delegates the raw byte IO to a pluggable `AttachmentFileStore` chosen by `PlatformService` (mirroring how `DatabaseService` picks a DB backend):
|
||||||
|
|
||||||
|
- **Electron** (`ElectronAttachmentFileStore`): real on-disk files via `window.electronAPI`; supports chunked reads and streamed (append) receive; `maxPersistableBytes = Infinity`.
|
||||||
|
- **Capacitor / Android** (`CapacitorAttachmentFileStore`): native `@capacitor/filesystem` under `Directory.Data` (lazy-loaded per the LESSONS rule); inline media is displayed through a `convertFileSrc` webview URL instead of a renderer `Blob`, avoiding large-media memory pressure on mobile; `maxPersistableBytes = Infinity`.
|
||||||
|
- **Browser** (`BrowserAttachmentFileStore`): a per-user IndexedDB virtual filesystem (`metoyou-attachment-files::<userId>`, store `files` keyed by path), so a user's own uploads and downloaded media survive reloads; `maxPersistableBytes = MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES` (50 MB) so very large media stays peer/in-memory only.
|
||||||
|
|
||||||
|
The transfer service consults `attachmentStorage.canStreamToDisk()` / `canPersistSize(size)` so the browser cap degrades gracefully (oversized media is kept in memory / peer-served instead of failing the disk path), and streamed receive only runs on stores with a real append primitive.
|
||||||
|
|
||||||
On Electron, local audio/video uploads are played through the original filesystem path when Electron exposes one, and received audio/video downloads are appended to an app-data file as chunks arrive. Completed audio/video downloads are then played through a file-backed media URL instead of being reloaded into a renderer `Blob`, which avoids full-file renderer memory pressure during download, startup restore, and playback. The storage path for downloaded server-room files is resolved per room and bucket:
|
On Electron, local audio/video uploads are played through the original filesystem path when Electron exposes one, and received audio/video downloads are appended to an app-data file as chunks arrive. Completed audio/video downloads are then played through a file-backed media URL instead of being reloaded into a renderer `Blob`, which avoids full-file renderer memory pressure during download, startup restore, and playback. The storage path for downloaded server-room files is resolved per room and bucket:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -155,7 +183,7 @@ Direct-message attachments use the conversation id instead of the server-room pa
|
|||||||
|
|
||||||
Room and conversation names are sanitised to remove filesystem-unsafe characters. The bucket is `video`, `audio`, `image`, or `files` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
|
Room and conversation names are sanitised to remove filesystem-unsafe characters. The bucket is `video`, `audio`, `image`, or `files` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
|
||||||
|
|
||||||
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On Electron, saved audio/video records are restored as file-backed URLs; other restored files still need their bytes loaded when a `Blob` URL is required. On browser builds, files stay in memory only.
|
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On restore, `ensureInlineDisplayObjectUrl` resolves the stored path and, when the active store exposes a directly loadable URL (`providesInlineObjectUrl`, i.e. Capacitor), uses that URL as-is; otherwise it rebuilds a `Blob` from the stored bytes (Electron via chunked reads, browser via whole-file read with the correct MIME). Because the browser store persists bytes to IndexedDB, sent and received files are remembered across reload/restart on every platform.
|
||||||
|
|
||||||
## Runtime store
|
## Runtime store
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ describe('AttachmentPersistenceService', () => {
|
|||||||
readFile: ReturnType<typeof vi.fn>;
|
readFile: ReturnType<typeof vi.fn>;
|
||||||
readFileChunk: ReturnType<typeof vi.fn>;
|
readFileChunk: ReturnType<typeof vi.fn>;
|
||||||
getFileSize: ReturnType<typeof vi.fn>;
|
getFileSize: ReturnType<typeof vi.fn>;
|
||||||
|
getFileUrl: ReturnType<typeof vi.fn>;
|
||||||
canReadFileChunks: ReturnType<typeof vi.fn>;
|
canReadFileChunks: ReturnType<typeof vi.fn>;
|
||||||
|
providesInlineObjectUrl: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -60,7 +62,9 @@ describe('AttachmentPersistenceService', () => {
|
|||||||
readFile: vi.fn(() => Promise.resolve('QUJD')),
|
readFile: vi.fn(() => Promise.resolve('QUJD')),
|
||||||
readFileChunk: vi.fn(() => Promise.resolve('QUJD')),
|
readFileChunk: vi.fn(() => Promise.resolve('QUJD')),
|
||||||
getFileSize: vi.fn(() => Promise.resolve(3)),
|
getFileSize: vi.fn(() => Promise.resolve(3)),
|
||||||
canReadFileChunks: vi.fn(() => true)
|
getFileUrl: vi.fn(() => Promise.resolve(null)),
|
||||||
|
canReadFileChunks: vi.fn(() => true),
|
||||||
|
providesInlineObjectUrl: vi.fn(() => false)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,4 +116,57 @@ describe('AttachmentPersistenceService', () => {
|
|||||||
expect(attachmentStorage.readFileChunk).toHaveBeenCalled();
|
expect(attachmentStorage.readFileChunk).toHaveBeenCalled();
|
||||||
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
|
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores a blob from a whole-file read when the store cannot read chunks (browser store)', async () => {
|
||||||
|
attachmentStorage.canReadFileChunks.mockReturnValue(false);
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
await service.initFromDatabase();
|
||||||
|
|
||||||
|
const attachment = {
|
||||||
|
id: 'att-1',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
filename: 'photo.png',
|
||||||
|
size: 3,
|
||||||
|
mime: 'image/png',
|
||||||
|
isImage: true,
|
||||||
|
savedPath: '/appdata/photo.png',
|
||||||
|
available: false
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
|
||||||
|
expect(attachment.available).toBe(true);
|
||||||
|
expect(attachment.objectUrl).toMatch(/^blob:/);
|
||||||
|
expect(attachmentStorage.readFile).toHaveBeenCalledWith('/appdata/photo.png');
|
||||||
|
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a native webview URL without rebuilding a blob (capacitor store)', async () => {
|
||||||
|
attachmentStorage.providesInlineObjectUrl.mockReturnValue(true);
|
||||||
|
attachmentStorage.resolveExistingPath.mockResolvedValue('metoyou/server/room/video/clip.mp4');
|
||||||
|
attachmentStorage.getFileUrl.mockResolvedValue('capacitor://localhost/_capacitor_file_/clip.mp4');
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
await service.initFromDatabase();
|
||||||
|
|
||||||
|
const attachment = {
|
||||||
|
id: 'att-1',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
filename: 'clip.mp4',
|
||||||
|
size: 1_024,
|
||||||
|
mime: 'video/mp4',
|
||||||
|
isImage: false,
|
||||||
|
savedPath: 'metoyou/server/room/video/clip.mp4',
|
||||||
|
available: false
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
|
||||||
|
expect(attachment.available).toBe(true);
|
||||||
|
expect(attachment.objectUrl).toBe('capacitor://localhost/_capacitor_file_/clip.mp4');
|
||||||
|
expect(attachmentStorage.getFileUrl).toHaveBeenCalledWith('metoyou/server/room/video/clip.mp4');
|
||||||
|
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
|
||||||
|
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||