Compare commits
24 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 | |||
| d0aff6319d | |||
| 1274ad9b46 |
@@ -195,7 +195,7 @@ export class LoginPage {
|
|||||||
| ------------------- | ------------------ | ----------------------- |
|
| ------------------- | ------------------ | ----------------------- |
|
||||||
| `/login` | `LoginPage` | `LoginComponent` |
|
| `/login` | `LoginPage` | `LoginComponent` |
|
||||||
| `/register` | `RegisterPage` | `RegisterComponent` |
|
| `/register` | `RegisterPage` | `RegisterComponent` |
|
||||||
| `/search` | `ServerSearchPage` | `ServerSearchComponent` |
|
| `/servers` | `FindServersPage` | `FindServersComponent` |
|
||||||
| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` |
|
| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` |
|
||||||
| `/settings` | `SettingsPage` | `SettingsComponent` |
|
| `/settings` | `SettingsPage` | `SettingsComponent` |
|
||||||
| `/invite/:inviteId` | `InvitePage` | `InviteComponent` |
|
| `/invite/:inviteId` | `InvitePage` | `InviteComponent` |
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Reference on-demand (when the workflow triggers them — see `agents-docs/AGENT_
|
|||||||
|
|
||||||
- `agents-docs/AGENTS_CONTEXT.md` — contract for updating `CONTEXT.md` / `CONTEXT-MAP.md`
|
- `agents-docs/AGENTS_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,76 @@ 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]
|
||||||
|
|
||||||
|
- **Trigger:** the find-servers route was renamed `/search` → `/servers` in `app.routes.ts`, but `servers-rail.component.ts` still called `router.navigate(['/search'])` (leave-server) and matched `startsWith('/search')` for the user-bar visibility signal, throwing `NG04002: 'search'` on leave and never showing the user-bar on the discovery page.
|
||||||
|
- **Rule:** after changing a `path:` in `app.routes.ts`, grep the whole repo for the old literal (`/search`) across `*.ts`/`*.html` (router calls, `startsWith`/url-match signals) and docs (`docs-site`, `.agents/skills/playwright-e2e/SKILL.md` route tables, domain READMEs) and update them all in the same change.
|
||||||
|
- **Why:** `router.navigate` to a non-existent path raises `NG04002` and aborts navigation, and stale `startsWith` matches silently break route-derived UI state — neither is caught by the build (string literals) and there was no `servers-rail` spec to catch it.
|
||||||
|
- **Example:** fixed `isOnServers`/`router.navigate(['/servers'])` in `servers-rail.component.{ts,html}`; canonical post-leave/discovery route is `/servers` (`FindServersComponent`), matching `DashboardComponent`'s `router.navigate(['/servers'])`.
|
||||||
|
|
||||||
|
### Server discovery must fan out across all endpoints and self-heal on 404 — never hardcode a host capability blocklist [server-directory]
|
||||||
|
|
||||||
|
- **Trigger:** the dashboard "Popular Servers" and `/servers` discovery view were empty for fresh users until they typed a search. The first fix added a static `DISCOVERY_UNSUPPORTED_HOSTS` blocklist (`signal.toju.app` / `signal-sweden.toju.app`) that short-circuited discovery to `[]`; the production hosts later shipped the `/featured` + `/trending` routes (verified `curl` → 200 with servers), so the stale blocklist kept blocking exactly the default endpoints a fresh account has while ungated search still surfaced them.
|
||||||
|
- **Rule:** discovery (`getFeaturedServers`/`getTrendingServers`) must fan out across `getSearchableEndpoints()` with `forkJoin` + `deduplicateById` (mirroring all-endpoint search), and detect capability *at runtime* — on a `404` from `/api/servers/{featured,trending}`, fall back per-endpoint to the public `GET /api/servers` listing (`fetchPublicServerListForDiscovery`) instead of returning `[]`. Do not maintain a hardcoded list of hosts that "don't support" a route; it goes stale silently and the build can't catch it.
|
||||||
|
- **Why:** legacy servers resolve `/featured` as `/servers/:id` and answer 404, so a 404→fallback keeps the default view populated everywhere without a blocklist; the empty-query view renders discovery sections (not search results), so any divergence between discovery and search makes it look broken while search works.
|
||||||
|
- **Example:** `fetchDiscoveryFromEndpoint` + `fetchPublicServerListForDiscovery` in `server-directory-api.service.ts`; `e2e/tests/servers/server-discovery-default.spec.ts` proves a fresh account sees Popular Servers without searching AND that route-intercepting `/featured`+`/trending` to 404 still populates it via the fallback.
|
||||||
|
|
||||||
### Server registration needs `ownerPublicKey: oderId || id`, and must not be fire-and-forget [server-directory] [rooms]
|
### Server registration needs `ownerPublicKey: oderId || id`, and must not be fire-and-forget [server-directory] [rooms]
|
||||||
|
|
||||||
- **Trigger:** creating a server appeared to work (the creator landed in the room view) but the server didn't exist on the backend — invite-link creation and search both 404'd. `createRoom$` sent `ownerPublicKey: currentUser.oderId` with no fallback; on restored sessions `oderId` can be falsy (identify still works because it falls back to `id`), so `POST /api/servers` returned `400 Missing required fields`, and the `.subscribe()` swallowed the error while `createRoomSuccess` fired regardless.
|
- **Trigger:** creating a server appeared to work (the creator landed in the room view) but the server didn't exist on the backend — invite-link creation and search both 404'd. `createRoom$` sent `ownerPublicKey: currentUser.oderId` with no fallback; on restored sessions `oderId` can be falsy (identify still works because it falls back to `id`), so `POST /api/servers` returned `400 Missing required fields`, and the `.subscribe()` swallowed the error while `createRoomSuccess` fired regardless.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -52,8 +58,9 @@ 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-install UUID generated by the product client (`metoyou.clientInstanceId` in `localStorage`). 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
|
||||||
|
|
||||||
@@ -62,6 +69,40 @@ Require `Authorization: Bearer`:
|
|||||||
- Voice/WebRTC is exclusive per user: only one `clientInstanceId` may own active voice at a time. Other connections show passive UI and can send `voice_client_takeover` to move voice to the local device.
|
- Voice/WebRTC is exclusive per user: only one `clientInstanceId` may own active voice at a time. Other connections show passive UI and can send `voice_client_takeover` to move voice to the local device.
|
||||||
- Stale reconnect hygiene: when a client re-identifies with the same `(oderId, connectionScope, clientInstanceId)` tuple, the server closes the older socket for that tuple.
|
- Stale reconnect hygiene: when a client re-identifies with the same `(oderId, connectionScope, clientInstanceId)` tuple, the server closes the older socket for that tuple.
|
||||||
|
|
||||||
|
### Account-owned state sync (`account_sync`)
|
||||||
|
|
||||||
|
When the same account is logged in on multiple devices, account-owned data is kept in sync through the signaling server:
|
||||||
|
|
||||||
|
| Data | Mechanism |
|
||||||
|
|---|---|
|
||||||
|
| Server chat messages (live) | `chat_message` signaling relay (connection-scoped broadcast) **plus** `account_sync` `chat-message` / `message-revision` to sibling devices |
|
||||||
|
| Server chat messages (catch-up) | `account_sync` `chat-sync-batch` pushed when a sibling device comes online (`account_sync_peer_online`); each batch carries its messages' **attachment metadata** (`attachments` map, local paths stripped) so sibling devices learn about synced attachments — they are then requestable/downloadable but never marked "Shared from your device" unless the bytes are local |
|
||||||
|
| Voice / typing | Existing `voice_state` / `user_typing` relays |
|
||||||
|
| Saved servers (join/leave) | `account_sync` payload `saved-room-sync` / `saved-room-remove` |
|
||||||
|
| Profile avatar + card text | `account_sync` `user-avatar-full` + `user-avatar-chunk` |
|
||||||
|
| Custom emoji library | `account_sync` `custom-emoji-full` + `custom-emoji-chunk` |
|
||||||
|
| Friends list | `account_sync` `friend-added` / `friend-removed` |
|
||||||
|
| Server icons, edits, reactions | `account_sync` relay of existing P2P broadcast event types |
|
||||||
|
|
||||||
|
Client rules:
|
||||||
|
|
||||||
|
- `broadcastMessage()` still fans out over peer data channels; relayable events are **also** wrapped in `account_sync` and sent on the WebSocket.
|
||||||
|
- The server forwards `account_sync` to every other open connection for the same `oderId` via `notifyOtherConnectionsForOderId`.
|
||||||
|
- Receivers ignore payloads whose `clientInstanceId` matches the local tab id.
|
||||||
|
- When a new device identifies, the server notifies existing connections with `account_sync_peer_online`; those devices push a full snapshot (saved rooms, **room message history**, friends, profile, emoji library).
|
||||||
|
|
||||||
|
WebSocket envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "account_sync",
|
||||||
|
"clientInstanceId": "<per-tab-uuid>",
|
||||||
|
"payload": { "type": "saved-room-sync", "room": { "...": "..." } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Server response to other connections includes `fromUserId` set to the sender's `oderId`.
|
||||||
|
|
||||||
## Client storage
|
## Client storage
|
||||||
|
|
||||||
The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server.
|
The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server.
|
||||||
@@ -76,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 \
|
||||||
|
|||||||
@@ -10,14 +10,20 @@ This page maps the app routes and important DOM areas. It is useful for plugin a
|
|||||||
|
|
||||||
| Route | Component | Purpose |
|
| Route | Component | Purpose |
|
||||||
| ---------------------------- | ------------------------- | --------------------------------------------------------------------- |
|
| ---------------------------- | ------------------------- | --------------------------------------------------------------------- |
|
||||||
| `/` | Redirect | Redirects to `/search`. |
|
| `/` | Redirect | Redirects to `/dashboard`. |
|
||||||
| `/login` | `LoginComponent` | User login. |
|
| `/login` | `LoginComponent` | User login. |
|
||||||
| `/register` | `RegisterComponent` | User registration. |
|
| `/register` | `RegisterComponent` | User registration. |
|
||||||
| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. |
|
| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. |
|
||||||
| `/search` | `ServerSearchComponent` | Search and join servers. |
|
| `/dashboard` | `DashboardComponent` | Landing dashboard after sign-in. |
|
||||||
|
| `/people` | `FindPeopleComponent` | Discover and start direct messages with people. |
|
||||||
|
| `/servers` | `FindServersComponent` | Search, discover, and join servers. |
|
||||||
|
| `/create-server` | `CreateServerComponent` | Create a new server. |
|
||||||
| `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. |
|
| `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. |
|
||||||
| `/dm` | `DmWorkspaceComponent` | Direct-message workspace. |
|
| `/dm` | `DmWorkspaceComponent` | Direct-message workspace. |
|
||||||
| `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. |
|
| `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. |
|
||||||
|
| `/pm` | `DmWorkspaceComponent` | Private-message workspace (alias of the DM workspace). |
|
||||||
|
| `/pm/:conversationId` | `DmWorkspaceComponent` | A selected private-message conversation. |
|
||||||
|
| `/call/:callId` | `PrivateCallComponent` | Active private (1:1) call. |
|
||||||
| `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. |
|
| `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. |
|
||||||
| `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. |
|
| `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. |
|
||||||
| `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. |
|
| `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. |
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ Important routes:
|
|||||||
|
|
||||||
| Route | Purpose |
|
| Route | Purpose |
|
||||||
| ------------------------------- | ------------------------------------------------------------------- |
|
| ------------------------------- | ------------------------------------------------------------------- |
|
||||||
| `/search` | Search and join servers. |
|
| `/servers` | Search, discover, and join servers. |
|
||||||
| `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. |
|
| `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. |
|
||||||
| `/dm` and `/dm/:conversationId` | Direct-message workspace. |
|
| `/dm` and `/dm/:conversationId` | Direct-message workspace. |
|
||||||
| `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. |
|
| `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. |
|
||||||
|
|||||||
@@ -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'])
|
||||||
@@ -220,4 +221,51 @@ describe('server websocket handler - multi-client sessions', () => {
|
|||||||
expect((stale.ws as WebSocket & { closeCalled: boolean }).closeCalled).toBe(true);
|
expect((stale.ws as WebSocket & { closeCalled: boolean }).closeCalled).toBe(true);
|
||||||
expect(connectedUsers.get('conn-new')?.authenticated).toBe(true);
|
expect(connectedUsers.get('conn-new')?.authenticated).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('relays account_sync payloads to other connections for the same user', async () => {
|
||||||
|
createConnectedUser('conn-a1', {
|
||||||
|
authenticated: true,
|
||||||
|
oderId: 'user-1',
|
||||||
|
serverIds: new Set(['server-1']),
|
||||||
|
clientInstanceId: 'device-a'
|
||||||
|
});
|
||||||
|
|
||||||
|
const receiver = createConnectedUser('conn-a2', {
|
||||||
|
authenticated: true,
|
||||||
|
oderId: 'user-1',
|
||||||
|
serverIds: new Set(['server-1']),
|
||||||
|
clientInstanceId: 'device-b'
|
||||||
|
});
|
||||||
|
|
||||||
|
getSentMessages(receiver).length = 0;
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-a1', {
|
||||||
|
type: 'account_sync',
|
||||||
|
clientInstanceId: 'device-a',
|
||||||
|
payload: {
|
||||||
|
type: 'friend-added',
|
||||||
|
userId: 'friend-1',
|
||||||
|
addedAt: 123
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = getSentMessages(receiver).map((raw) => JSON.parse(raw) as {
|
||||||
|
type: string;
|
||||||
|
payload?: { type: string; userId?: string };
|
||||||
|
clientInstanceId?: string;
|
||||||
|
fromUserId?: string;
|
||||||
|
});
|
||||||
|
const relay = messages.find((message) => message.type === 'account_sync');
|
||||||
|
|
||||||
|
expect(relay).toEqual({
|
||||||
|
type: 'account_sync',
|
||||||
|
clientInstanceId: 'device-a',
|
||||||
|
fromUserId: 'user-1',
|
||||||
|
payload: {
|
||||||
|
type: 'friend-added',
|
||||||
|
userId: 'friend-1',
|
||||||
|
addedAt: 123
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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',
|
||||||
@@ -296,6 +349,11 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
|
|||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||||
|
|
||||||
|
notifyOtherConnectionsForOderId(newOderId, {
|
||||||
|
type: 'account_sync_peer_online',
|
||||||
|
clientInstanceId: newClientInstanceId
|
||||||
|
}, connectionId);
|
||||||
|
|
||||||
const voiceSnapshot = Array.from(connectedUsers.entries()).find(([otherConnectionId, otherUser]) =>
|
const voiceSnapshot = Array.from(connectedUsers.entries()).find(([otherConnectionId, otherUser]) =>
|
||||||
otherConnectionId !== connectionId
|
otherConnectionId !== connectionId
|
||||||
&& otherUser.oderId === newOderId
|
&& otherUser.oderId === newOderId
|
||||||
@@ -541,6 +599,21 @@ function handleVoiceClientTakeover(user: ConnectedUser, message: WsMessage, conn
|
|||||||
}, connectionId);
|
}, connectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAccountSync(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
|
const payload = message['payload'];
|
||||||
|
|
||||||
|
if (!payload || typeof payload !== 'object' || typeof (payload as { type?: unknown }).type !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyOtherConnectionsForOderId(user.oderId, {
|
||||||
|
type: 'account_sync',
|
||||||
|
clientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId,
|
||||||
|
fromUserId: user.oderId,
|
||||||
|
payload
|
||||||
|
}, connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
function handleTyping(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleTyping(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||||
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
|
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
|
||||||
@@ -685,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)
|
||||||
@@ -747,6 +845,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
handleVoiceClientTakeover(user, message, connectionId);
|
handleVoiceClientTakeover(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'account_sync':
|
||||||
|
handleAccountSync(user, message, connectionId);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'typing':
|
case 'typing':
|
||||||
handleTyping(user, message, connectionId);
|
handleTyping(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { usersReducer } from './store/users/users.reducer';
|
|||||||
import { roomsReducer } from './store/rooms/rooms.reducer';
|
import { roomsReducer } from './store/rooms/rooms.reducer';
|
||||||
import { NotificationsEffects } from './domains/notifications';
|
import { NotificationsEffects } from './domains/notifications';
|
||||||
import { CustomEmojiSyncEffects } from './domains/custom-emoji';
|
import { CustomEmojiSyncEffects } from './domains/custom-emoji';
|
||||||
|
import { AccountSyncEffects } from './infrastructure/realtime/account-sync/account-sync.effects';
|
||||||
import { MessagesEffects } from './store/messages/messages.effects';
|
import { MessagesEffects } from './store/messages/messages.effects';
|
||||||
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
||||||
import { UserAvatarEffects } from './store/users/user-avatar.effects';
|
import { UserAvatarEffects } from './store/users/user-avatar.effects';
|
||||||
@@ -45,6 +46,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
}),
|
}),
|
||||||
provideEffects([
|
provideEffects([
|
||||||
NotificationsEffects,
|
NotificationsEffects,
|
||||||
|
AccountSyncEffects,
|
||||||
CustomEmojiSyncEffects,
|
CustomEmojiSyncEffects,
|
||||||
MessagesEffects,
|
MessagesEffects,
|
||||||
MessagesSyncEffects,
|
MessagesSyncEffects,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||