feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay

- Fix 80 E2E test failures across 6 root causes (channel auto-select,
  settings overlay wiring, QuickSwitcher Ctrl+K, voice widget visibility,
  member list rendering, status dot positioning)
- Add Tauri credential storage (Rust + TS bridge) and window-state persistence
- Add ConnectedOverlay component and settings-overlay/window-state unit tests
- Expand profiles and rate-limiter with comprehensive test coverage
- Add CODE_REVIEW.md documenting 4 Critical + 3 High server-side issues
- Add Playwright E2E suite (135 tests across 14 spec files)
- All 586 tests passing (451 unit/integration + 135 E2E)
This commit is contained in:
jevb
2026-03-16 16:43:46 +01:00
parent 3d022b68e4
commit 01387dc033
45 changed files with 5155 additions and 494 deletions
+25
View File
@@ -0,0 +1,25 @@
# Code Review Findings (2026-03-16)
Scope: server (Go), Tauri client (TS/Rust), and spec/docs alignment. I did not run tests.
**Critical**
1. Authorization bypass for read access to channels and messages. Any authenticated client can set `channel_focus` to any channel ID and receive broadcasts, and REST endpoints return channels/messages/search results without permission checks. Impact: data exposure across private channels. Evidence: `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\handlers.go:426-435`, `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\hub.go:216-301`, `D:\Local-Lab\Coding\Repos\OwnCord\Server\api\channel_handler.go:16-176`. Recommendation: enforce `READ_MESSAGES` (and visibility) for `channel_focus`, `GET /channels`, `GET /channels/{id}/messages`, and `GET /search`, and track per-user channel access in hub routing.
2. WebSocket auth failure handling is incompatible between server and client. Server emits `type: "error"` with code `AUTH_ERROR` and then closes; client only treats `auth_error` as non-recoverable and will reconnect forever with a bad token. Impact: infinite reconnect loop and no clear UX on auth failure. Evidence: `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\serve.go:127-165`, `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\messages.go:27-35`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\lib\ws.ts:145-152`. Recommendation: emit `auth_error` with the spec payload and have the client stop reconnecting on auth errors.
3. Member/role protocol mismatches will crash or mis-render the client. Server emits `member_join` as a flat payload with `role_id`, and `ready` members and `auth_ok` omit role name entirely. Client expects `payload.user` with a role string and uses it for UI. Impact: runtime exceptions on join and incorrect role display. Evidence: `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\messages.go:49-64`, `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\serve.go:171-191`, `D:\Local-Lab\Coding\Repos\OwnCord\Server\db\auth_queries.go:290-320`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\lib\types.ts:61-104`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\stores\members.store.ts:58-69`. Recommendation: align server WS payloads to `UserWithRole` or update client to accept current server shapes.
4. `chat_message` WS payload omits `attachments`, and the client assumes it exists. This can throw at render time (`for ... of msg.attachments`) and drop messages. Impact: client crashes on any message if `attachments` is undefined. Evidence: `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\messages.go:67-87`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\stores\messages.store.ts:51-63`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\components\MessageList.ts:324-327`. Recommendation: always include `attachments: []` in WS payloads or make client defaults defensive.
**High**
1. REST message/search responses do not match API spec or client types. `GET /channels/{id}/messages` returns `MessageWithUser` (username/avatar only) without `user` object, attachments, reactions, pinned, or deleted fields. Search returns `username` instead of `user`. Impact: REST clients (including the Tauri client) will mis-parse or lose required data. Evidence: `D:\Local-Lab\Coding\Repos\OwnCord\Server\api\channel_handler.go:97-117`, `D:\Local-Lab\Coding\Repos\OwnCord\Server\db\models.go:85-107`, `D:\Local-Lab\Coding\Repos\OwnCord\Server\db\message_queries.go:35-80`, `D:\Local-Lab\Coding\Repos\OwnCord\Server\db\message_queries.go:185-236`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\lib\types.ts:478-520`, `D:\Local-Lab\Coding\Repos\OwnCord\API.md:65-132`. Recommendation: update REST handlers and DB queries to return the documented shapes (including attachments/reactions/user object), or update API.md and client types to match reality.
2. Health check endpoint mismatch. Server exposes `/health`, while the client probes `/api/v1/health`, and API.md documents `/api/health`. Impact: health checks always fail; status badges will show offline. Evidence: `D:\Local-Lab\Coding\Repos\OwnCord\Server\api\router.go:33-35`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\lib\api.ts:403-418`, `D:\Local-Lab\Coding\Repos\OwnCord\API.md:260-268`. Recommendation: choose one canonical health path and update server, client, and docs to match.
3. Attachments are parsed on `chat_send` but never validated or persisted. The server accepts an attachments array but ignores it entirely. Impact: attachments feature appears to work client-side but messages will drop attachments and cant be retrieved later; also no access validation on attachment IDs. Evidence: `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\handlers.go:123-203`. Recommendation: validate attachment IDs against uploads table, enforce permissions, persist the relationship, and include attachments in WS/REST responses.
**Medium**
1. WS heartbeat sends `ping` messages that the server treats as unknown and responds with `error`. Impact: noisy logs, unnecessary error traffic, and potential user-visible errors if surfaced. Evidence: `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\lib\ws.ts:86-96`, `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\handlers.go:71-110`. Recommendation: implement a `ping` handler (or disable client heartbeat if server doesnt need it).
2. API base path is inconsistent across docs and guidance. `API.md` says `/api` while `CLAUDE.md` says `/api/v1`, and the server/router+client are `/api/v1`. Impact: contributors and third-party clients will integrate against the wrong base URL. Evidence: `D:\Local-Lab\Coding\Repos\OwnCord\API.md:3-18`, `D:\Local-Lab\Coding\Repos\OwnCord\CLAUDE.md:21-22`, `D:\Local-Lab\Coding\Repos\OwnCord\Server\api\router.go:40-68`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\lib\api.ts:52-54`. Recommendation: pick a single base path, then update code and docs consistently.
**Low**
1. `auth_ok` does not include role, but UI expects role-based color coding. Even if `member_join` and `ready` are fixed, initial auth state will still lack role. Impact: inconsistent role display until ready arrives. Evidence: `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\serve.go:171-191`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\lib\types.ts:163-167`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\lib\dispatcher.ts:55-63`. Recommendation: include role in `auth_ok` or adjust client to tolerate missing role until ready.
**Test Gaps**
1. No automated coverage for authorization of channel read access (REST and WS channel focus). Given the permission system, this should have dedicated tests to prevent regressions. Suggested targets: `D:\Local-Lab\Coding\Repos\OwnCord\Server\api\channel_handler_test.go` and WS tests in `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\handlers_test.go`.
2. No contract tests asserting server responses match `API.md` and `PROTOCOL.md`. The current drift would have been caught by simple golden tests.
+64
View File
@@ -15,6 +15,7 @@
"@tauri-apps/plugin-store": "^2"
},
"devDependencies": {
"@playwright/test": "^1",
"@tauri-apps/cli": "^2",
"@vitest/coverage-v8": "^3",
"jsdom": "^29.0.0",
@@ -849,6 +850,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
@@ -2430,6 +2447,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+2
View File
@@ -12,10 +12,12 @@
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"devDependencies": {
"@playwright/test": "^1",
"@tauri-apps/cli": "^2",
"@vitest/coverage-v8": "^3",
"jsdom": "^29.0.0",
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
expect: {
timeout: 5_000,
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:1420",
screenshot: "only-on-failure",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:1420",
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
});
+79 -14
View File
@@ -2505,6 +2505,7 @@ dependencies = [
"tauri-plugin-store",
"tokio",
"tokio-tungstenite",
"windows 0.58.0",
]
[[package]]
@@ -3996,7 +3997,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -4067,7 +4068,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -4268,7 +4269,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -4293,7 +4294,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -4354,7 +4355,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
"windows",
"windows 0.61.3",
"windows-version",
]
@@ -5197,10 +5198,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
]
[[package]]
@@ -5221,7 +5222,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -5271,6 +5272,16 @@ dependencies = [
"windows-version",
]
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core 0.58.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.61.3"
@@ -5293,14 +5304,27 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement 0.58.0",
"windows-interface 0.58.0",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@@ -5312,8 +5336,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
@@ -5330,6 +5354,17 @@ dependencies = [
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -5341,6 +5376,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
@@ -5385,6 +5431,15 @@ dependencies = [
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -5403,6 +5458,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
@@ -5830,7 +5895,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
+3
View File
@@ -23,3 +23,6 @@ tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"]
futures-util = "0.3.32"
tokio = { version = "1", features = ["sync"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = ["Win32_Security_Credentials", "Win32_Foundation"] }
@@ -0,0 +1,171 @@
use serde::Serialize;
use std::ptr;
use windows::core::{PCWSTR, PWSTR};
use windows::Win32::Foundation::ERROR_NOT_FOUND;
use windows::Win32::Security::Credentials::{
CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_FLAGS,
CRED_PERSIST_LOCAL_MACHINE, CRED_TYPE_GENERIC,
};
/// Data returned from `load_credential`.
#[derive(Serialize, Clone, Debug)]
pub struct CredentialData {
pub username: String,
pub token: String,
}
/// Build the target name used in Windows Credential Manager.
fn target_name(host: &str) -> Vec<u16> {
let name = format!("OwnCord/{host}");
name.encode_utf16().chain(std::iter::once(0)).collect()
}
/// Encode a Rust string as a null-terminated UTF-16 vector.
fn to_wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
/// Save a credential (username + token) to Windows Credential Manager.
///
/// Target name: `OwnCord/{host}`
/// Blob: JSON `{"username":"...","token":"..."}`
#[tauri::command]
pub fn save_credential(host: String, username: String, token: String) -> Result<(), String> {
if host.is_empty() {
return Err("host must not be empty".into());
}
if token.is_empty() {
return Err("token must not be empty".into());
}
if username.is_empty() {
return Err("username must not be empty".into());
}
let target = target_name(&host);
let wide_user = to_wide(&username);
let payload = serde_json::json!({
"username": username,
"token": token,
});
let blob = payload.to_string().into_bytes();
let mut cred = CREDENTIALW {
Flags: CRED_FLAGS(0),
Type: CRED_TYPE_GENERIC,
TargetName: PWSTR(target.as_ptr() as *mut u16),
Comment: PWSTR::null(),
LastWritten: Default::default(),
CredentialBlobSize: blob.len() as u32,
CredentialBlob: blob.as_ptr() as *mut u8,
Persist: CRED_PERSIST_LOCAL_MACHINE,
AttributeCount: 0,
Attributes: ptr::null_mut(),
TargetAlias: PWSTR::null(),
UserName: PWSTR(wide_user.as_ptr() as *mut u16),
};
unsafe {
CredWriteW(&mut cred, 0)
.map_err(|e| format!("CredWriteW failed: {e}"))?;
}
Ok(())
}
/// Load a credential from Windows Credential Manager.
///
/// Returns `None` when no credential exists for the given host.
#[tauri::command]
pub fn load_credential(host: String) -> Result<Option<CredentialData>, String> {
if host.is_empty() {
return Err("host must not be empty".into());
}
let target = target_name(&host);
let mut pcred: *mut CREDENTIALW = ptr::null_mut();
let read_result = unsafe {
CredReadW(
PCWSTR(target.as_ptr()),
CRED_TYPE_GENERIC,
0,
&mut pcred,
)
};
match read_result {
Ok(()) => {}
Err(e) => {
if e.code() == ERROR_NOT_FOUND.to_hresult() {
return Ok(None);
}
return Err(format!("CredReadW failed: {e}"));
}
}
// SAFETY: `pcred` is valid after a successful CredReadW call.
let result = unsafe {
let cred = &*pcred;
let blob_slice = std::slice::from_raw_parts(
cred.CredentialBlob,
cred.CredentialBlobSize as usize,
);
let json_str = String::from_utf8(blob_slice.to_vec())
.map_err(|e| format!("credential blob is not valid UTF-8: {e}"))?;
let parsed: serde_json::Value = serde_json::from_str(&json_str)
.map_err(|e| format!("credential blob is not valid JSON: {e}"))?;
let username = parsed
.get("username")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let token = parsed
.get("token")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
// Free the credential memory allocated by Windows.
CredFree(pcred as *const std::ffi::c_void);
Ok(Some(CredentialData { username, token }))
};
result
}
/// Delete a credential from Windows Credential Manager.
#[tauri::command]
pub fn delete_credential(host: String) -> Result<(), String> {
if host.is_empty() {
return Err("host must not be empty".into());
}
let target = target_name(&host);
let delete_result = unsafe {
CredDeleteW(
PCWSTR(target.as_ptr()),
CRED_TYPE_GENERIC,
0,
)
};
match delete_result {
Ok(()) => Ok(()),
Err(e) => {
if e.code() == ERROR_NOT_FOUND.to_hresult() {
// Deleting a non-existent credential is not an error.
return Ok(());
}
Err(format!("CredDeleteW failed: {e}"))
}
}
}
+4
View File
@@ -1,4 +1,5 @@
mod commands;
mod credentials;
mod hotkeys;
mod tray;
mod ws_proxy;
@@ -19,6 +20,9 @@ pub fn run() {
ws_proxy::ws_connect,
ws_proxy::ws_send,
ws_proxy::ws_disconnect,
credentials::save_credential,
credentials::load_credential,
credentials::delete_credential,
])
.setup(|app| {
tray::create_tray(app.handle())?;
@@ -0,0 +1,118 @@
/**
* ConnectedOverlay — full-screen overlay shown after auth_ok,
* displays server info while waiting for the ready event.
* Matches login-mockup.html connected overlay structure.
*/
import { createElement, setText, appendChildren } from "@lib/dom";
export interface ConnectedOverlayOptions {
readonly serverName: string;
readonly username: string;
readonly motd: string;
readonly onReady: () => void;
}
export interface ConnectedOverlayControl {
readonly element: HTMLDivElement;
/** Call when ready payload is received. */
markReady(): void;
/** Show the overlay (adds .visible class). */
show(): void;
destroy(): void;
}
const READY_DELAY_MS = 800;
function serverIconColor(name: string): string {
const palette = [
"#5865f2", "#57f287", "#fee75c", "#eb459e",
"#ed4245", "#f0b232", "#2ecc71", "#e74c3c",
] as const;
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) | 0;
}
return palette[Math.abs(hash) % palette.length] ?? palette[0];
}
export function createConnectedOverlay(
options: ConnectedOverlayOptions,
): ConnectedOverlayControl {
const { serverName, username, motd, onReady } = options;
const ac = new AbortController();
// Root overlay (hidden by default, .visible to show)
const overlay = createElement("div", { class: "connected-overlay" });
// Server icon with check badge
const iconWrap = createElement("div", { class: "connected-icon-wrap" });
const srvIcon = createElement("div", {
class: "connected-srv-icon",
style: `background:${serverIconColor(serverName)}`,
});
setText(srvIcon, serverName.charAt(0).toUpperCase());
// SVG checkmark badge (matches mockup)
const checkBadge = createElement("div", { class: "connected-check-badge" });
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "3");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
polyline.setAttribute("points", "20 6 9 17 4 12");
svg.appendChild(polyline);
checkBadge.appendChild(svg);
appendChildren(iconWrap, srvIcon, checkBadge);
// Text elements
const connectedText = createElement("div", {
class: "connected-text",
}, "Connected!");
const userText = createElement("div", {
class: "connected-user",
}, `Logged in as ${username}`);
const motdEl = createElement("div", { class: "connected-motd" });
if (motd) {
setText(motdEl, motd);
}
// Loader with spinner
const loader = createElement("div", { class: "connected-loader" });
const spinner = createElement("div", { class: "spinner" });
const loaderText = createElement("span", {}, "Loading server data...");
appendChildren(loader, spinner, loaderText);
appendChildren(overlay, iconWrap, connectedText, userText, motdEl, loader);
function show(): void {
overlay.classList.add("visible");
}
function markReady(): void {
if (ac.signal.aborted) return;
spinner.style.display = "none";
setText(loaderText, "\u2714 Ready!");
const timer = setTimeout(() => {
if (!ac.signal.aborted) {
onReady();
}
}, READY_DELAY_MS);
ac.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
}
function destroy(): void {
ac.abort();
overlay.remove();
}
return { element: overlay, markReady, show, destroy };
}
@@ -5,6 +5,7 @@
import { createElement, appendChildren, setText } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import { createEmojiPicker } from "@components/EmojiPicker";
export interface MessageInputOptions {
readonly channelId: number;
@@ -152,6 +153,47 @@ export function createMessageInput(
sendBtn.addEventListener("click", handleSend, { signal });
// Emoji picker toggle
let emojiPicker: { element: HTMLDivElement; destroy(): void } | null = null;
function toggleEmojiPicker(): void {
if (emojiPicker !== null) {
emojiPicker.element.remove();
emojiPicker.destroy();
emojiPicker = null;
return;
}
emojiPicker = createEmojiPicker({
onSelect: (emoji: string) => {
if (textarea !== null) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const before = textarea.value.slice(0, start);
const after = textarea.value.slice(end);
textarea.value = before + emoji + after;
textarea.selectionStart = textarea.selectionEnd = start + emoji.length;
textarea.focus();
}
// Close after selection
if (emojiPicker !== null) {
emojiPicker.element.remove();
emojiPicker.destroy();
emojiPicker = null;
}
},
onClose: () => {
if (emojiPicker !== null) {
emojiPicker.element.remove();
emojiPicker.destroy();
emojiPicker = null;
}
},
});
root?.appendChild(emojiPicker.element);
}
emojiBtn.addEventListener("click", toggleEmojiPicker, { signal });
appendChildren(inputBox, attachBtn, textarea, emojiBtn, sendBtn);
appendChildren(root, replyBar, editBar, inputBox);
container.appendChild(root);
@@ -142,7 +142,10 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
function mount(container: Element): void {
// Overlay backdrop
root = createElement("div", { class: "quick-switcher-overlay" });
root = createElement("div", {
class: "quick-switcher-overlay",
style: "position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; justify-content: center; padding-top: 20vh;",
});
// Modal container
const modal = createElement("div", { class: "quick-switcher" });
@@ -22,12 +22,13 @@ export interface SettingsOverlayOptions {
onLogout(): void;
}
type TabName = "Account" | "Appearance" | "Notifications" | "Keybinds" | "Logs";
type TabName = "Account" | "Appearance" | "Notifications" | "Voice & Audio" | "Keybinds" | "Logs";
const TAB_NAMES: readonly TabName[] = [
"Account",
"Appearance",
"Notifications",
"Voice & Audio",
"Keybinds",
"Logs",
] as const;
@@ -299,6 +300,123 @@ export function createSettingsOverlay(
return section;
}
// ---- Voice & Audio tab ------------------------------------------------------
function buildVoiceAudioTab(): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const header = createElement("h1", {}, "Voice & Audio");
section.appendChild(header);
// Input device selector
const inputHeader = createElement("h3", {}, "Input Device");
const inputSelect = createElement("select", {
class: "form-input",
style: "width:100%;margin-bottom:12px",
});
const defaultInputOpt = createElement("option", { value: "" }, "Default");
inputSelect.appendChild(defaultInputOpt);
section.appendChild(inputHeader);
section.appendChild(inputSelect);
// Output device selector
const outputHeader = createElement("h3", {}, "Output Device");
const outputSelect = createElement("select", {
class: "form-input",
style: "width:100%;margin-bottom:12px",
});
const defaultOutputOpt = createElement("option", { value: "" }, "Default");
outputSelect.appendChild(defaultOutputOpt);
section.appendChild(outputHeader);
section.appendChild(outputSelect);
// Populate devices asynchronously
void (async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const savedInput = loadPref<string>("audioInputDevice", "");
const savedOutput = loadPref<string>("audioOutputDevice", "");
for (const d of devices) {
if (d.kind === "audioinput") {
const opt = createElement("option", { value: d.deviceId },
d.label || `Microphone (${d.deviceId.slice(0, 8)})`);
if (d.deviceId === savedInput) opt.setAttribute("selected", "");
inputSelect.appendChild(opt);
} else if (d.kind === "audiooutput") {
const opt = createElement("option", { value: d.deviceId },
d.label || `Speaker (${d.deviceId.slice(0, 8)})`);
if (d.deviceId === savedOutput) opt.setAttribute("selected", "");
outputSelect.appendChild(opt);
}
}
// Restore saved selections
if (savedInput) inputSelect.value = savedInput;
if (savedOutput) outputSelect.value = savedOutput;
} catch {
const errOpt = createElement("option", { value: "", disabled: "" },
"Could not enumerate devices");
inputSelect.appendChild(errOpt);
}
})();
inputSelect.addEventListener("change", () => {
savePref("audioInputDevice", inputSelect.value);
}, { signal: ac.signal });
outputSelect.addEventListener("change", () => {
savePref("audioOutputDevice", outputSelect.value);
}, { signal: ac.signal });
// Input sensitivity slider
const sensitivityHeader = createElement("h3", {}, "Input Sensitivity");
const sensitivityRow = createElement("div", { class: "slider-row" });
const savedSensitivity = loadPref<number>("voiceSensitivity", 50);
const sensitivitySlider = createElement("input", {
class: "settings-slider",
type: "range",
min: "0",
max: "100",
value: String(savedSensitivity),
});
const sensitivityLabel = createElement("span", { class: "slider-val" }, `${savedSensitivity}%`);
sensitivitySlider.addEventListener("input", () => {
const val = Number(sensitivitySlider.value);
setText(sensitivityLabel, `${val}%`);
savePref("voiceSensitivity", val);
}, { signal: ac.signal });
appendChildren(sensitivityRow, sensitivitySlider, sensitivityLabel);
appendChildren(section, sensitivityHeader, sensitivityRow);
// Audio processing toggles
const audioToggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [
{ key: "echoCancellation", label: "Echo Cancellation", desc: "Reduce echo from speakers feeding back into microphone", fallback: true },
{ key: "noiseSuppression", label: "Noise Suppression", desc: "Filter out background noise from your microphone", fallback: true },
{ key: "autoGainControl", label: "Automatic Gain Control", desc: "Automatically adjust microphone volume", fallback: true },
];
for (const item of audioToggles) {
const row = createElement("div", { class: "setting-row" });
const info = createElement("div", {});
const label = createElement("div", { class: "setting-label" }, item.label);
const desc = createElement("div", { class: "setting-desc" }, item.desc);
appendChildren(info, label, desc);
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createElement("div", { class: isOn ? "toggle on" : "toggle" });
toggle.addEventListener("click", () => {
const nowOn = !toggle.classList.contains("on");
toggle.classList.toggle("on", nowOn);
savePref(item.key, nowOn);
}, { signal: ac.signal });
appendChildren(row, info, toggle);
section.appendChild(row);
}
return section;
}
// ---- Logs tab ---------------------------------------------------------------
let logListEl: HTMLDivElement | null = null;
@@ -438,6 +556,7 @@ export function createSettingsOverlay(
Account: buildAccountTab,
Appearance: buildAppearanceTab,
Notifications: buildNotificationsTab,
"Voice & Audio": buildVoiceAudioTab,
Keybinds: buildKeybindsTab,
Logs: buildLogsTab,
};
@@ -15,6 +15,7 @@ export function createUserBar(): MountableComponent {
// Element references for targeted updates
let avatarEl: HTMLDivElement | null = null;
let avatarTextEl: HTMLSpanElement | null = null;
let nameEl: HTMLSpanElement | null = null;
let statusEl: HTMLSpanElement | null = null;
@@ -24,8 +25,8 @@ export function createUserBar(): MountableComponent {
const username = user?.username ?? "Unknown";
const initial = username.charAt(0).toUpperCase() || "?";
if (avatarEl !== null) {
setText(avatarEl, initial);
if (avatarTextEl !== null) {
setText(avatarTextEl, initial);
}
if (nameEl !== null) {
setText(nameEl, username);
@@ -40,11 +41,13 @@ export function createUserBar(): MountableComponent {
avatarEl = createElement(
"div",
{ class: "ub-avatar", style: "background: var(--accent)" },
{ class: "ub-avatar", style: "background: var(--accent); position: relative;" },
);
avatarTextEl = createElement("span", {});
avatarEl.appendChild(avatarTextEl);
const statusDot = createElement("div", {
class: "status-dot",
style: "background: var(--green)",
style: "background: var(--green); width: 10px; height: 10px; border-radius: 50%; position: absolute; bottom: 0; right: 0;",
});
avatarEl.appendChild(statusDot);
@@ -106,6 +109,7 @@ export function createUserBar(): MountableComponent {
root = null;
}
avatarEl = null;
avatarTextEl = null;
nameEl = null;
statusEl = null;
}
@@ -0,0 +1,91 @@
/**
* Credential storage — wraps Tauri IPC commands for Windows Credential Manager.
* Falls back to no-op in non-Tauri environments (tests, browser).
*/
import { createLogger } from "./logger";
const log = createLogger("credentials");
export interface SavedCredential {
readonly username: string;
readonly token: string;
}
/** Dynamically import Tauri invoke to avoid errors in test/browser. */
async function getInvoke(): Promise<
((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null
> {
try {
const { invoke } = await import("@tauri-apps/api/core");
return invoke;
} catch {
return null;
}
}
/**
* Save a credential to Windows Credential Manager.
* Target: OwnCord/{host}
*/
export async function saveCredential(
host: string,
username: string,
token: string,
): Promise<boolean> {
const invoke = await getInvoke();
if (!invoke) {
log.warn("Tauri not available — credential not saved");
return false;
}
try {
await invoke("save_credential", { host, username, token });
return true;
} catch (err) {
log.error("Failed to save credential", { host, error: String(err) });
return false;
}
}
/**
* Load a credential from Windows Credential Manager.
* Returns null if not found or Tauri unavailable.
*/
export async function loadCredential(
host: string,
): Promise<SavedCredential | null> {
const invoke = await getInvoke();
if (!invoke) {
return null;
}
try {
const result = await invoke("load_credential", { host });
if (result && typeof result === "object") {
const cred = result as Record<string, unknown>;
if (typeof cred.username === "string" && typeof cred.token === "string") {
return { username: cred.username, token: cred.token };
}
}
return null;
} catch (err) {
log.error("Failed to load credential", { host, error: String(err) });
return null;
}
}
/**
* Delete a credential from Windows Credential Manager.
*/
export async function deleteCredential(host: string): Promise<boolean> {
const invoke = await getInvoke();
if (!invoke) {
return false;
}
try {
await invoke("delete_credential", { host });
return true;
} catch (err) {
log.error("Failed to delete credential", { host, error: String(err) });
return false;
}
}
+17
View File
@@ -6,6 +6,7 @@ import type { WsClient } from "./ws";
import { authStore, setAuth, clearAuth } from "@stores/auth.store";
import {
setChannels,
setActiveChannel,
addChannel,
updateChannel,
removeChannel,
@@ -33,6 +34,7 @@ import {
removeVoiceUser,
setVoiceConfig,
setSpeakers,
joinVoiceChannel,
} from "@stores/voice.store";
import { createLogger } from "./logger";
@@ -75,6 +77,16 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
setChannels(payload.channels);
setMembers(payload.members);
setVoiceStates(payload.voice_states);
// Auto-select the first text channel if none is active
const currentActive = channelsStore.select((s) => s.activeChannelId);
if (currentActive === null && payload.channels.length > 0) {
const firstText = payload.channels.find((ch) => ch.type === "text");
if (firstText !== undefined) {
setActiveChannel(firstText.id);
}
}
log.info("Ready payload applied", {
channels: payload.channels.length,
members: payload.members.length,
@@ -194,6 +206,11 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
unsubs.push(
ws.on("voice_state", (payload) => {
updateVoiceState(payload);
// Auto-join voice channel if the event is for the current user
const currentUserId = authStore.getState().user?.id ?? 0;
if (payload.user_id === currentUserId) {
joinVoiceChannel(payload.channel_id);
}
}),
);
+304 -109
View File
@@ -1,190 +1,389 @@
/**
* Server profiles management service.
* Server profiles management module.
*
* Manages saved server connection profiles for OwnCord.
* Uses a pluggable StorageBackend so the real app can swap
* in tauri-plugin-store while tests use a simple Map backend.
* Manages saved server connection profiles for the OwnCord login page.
* Uses the createStore reactive pattern for state and Tauri invoke
* commands for persistence (mockable via dependency injection).
*/
import { createStore, type Store } from "./store";
import { fetch } from "@tauri-apps/plugin-http";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const STORAGE_KEY = "owncord:profiles";
const CURRENT_SCHEMA_VERSION = 1;
const HEALTH_TIMEOUT_MS = 3000;
const SLOW_THRESHOLD_MS = 1500;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ServerProfile {
readonly id: string;
readonly name: string;
readonly host: string;
readonly username: string;
readonly color: string;
readonly autoConnect: boolean;
readonly color: string;
readonly lastConnected: string | null;
}
export interface HealthStatus {
readonly status: "online" | "slow" | "offline" | "checking";
readonly latencyMs: number | null;
readonly version: string | null;
}
export interface ProfilesState {
readonly profiles: readonly ServerProfile[];
readonly healthStatuses: ReadonlyMap<string, HealthStatus>;
}
export type CreateProfileData = Omit<ServerProfile, "id" | "lastConnected">;
export type UpdateProfileData = Partial<Omit<ServerProfile, "id">>;
/** Schema-versioned persistence envelope. */
interface StoredData {
readonly schemaVersion: number;
readonly profiles: readonly ServerProfile[];
}
export interface StorageBackend {
get(key: string): string | null;
set(key: string, value: string): void;
remove(key: string): void;
/**
* Persistence backend abstraction.
* In production, wraps Tauri `invoke("save_settings", ...)` / `invoke("get_settings")`.
* In tests, can be replaced with a synchronous Map-backed implementation.
*/
export interface PersistenceBackend {
load(): Promise<StoredData | null>;
save(data: StoredData): Promise<void>;
}
export type CreateProfileData = Omit<
ServerProfile,
"id" | "schemaVersion" | "lastConnected"
>;
/**
* Fetch function type matching the Tauri HTTP plugin signature.
* Allows injection of a mock in tests.
*/
export type FetchFn = typeof globalThis.fetch;
export type UpdateProfileData = Partial<
Omit<ServerProfile, "id" | "schemaVersion">
>;
export interface ProfileManager {
getAll(): readonly ServerProfile[];
getById(id: string): ServerProfile | null;
create(data: CreateProfileData): ServerProfile;
update(id: string, data: UpdateProfileData): ServerProfile | null;
remove(id: string): boolean;
setLastConnected(id: string): void;
getAutoConnect(): ServerProfile | null;
exportProfiles(): string;
importProfiles(json: string): { imported: number; skipped: number };
migrate(): void;
}
const localStorageBackend: StorageBackend = {
get(key: string): string | null {
return localStorage.getItem(key);
},
set(key: string, value: string): void {
localStorage.setItem(key, value);
},
remove(key: string): void {
localStorage.removeItem(key);
},
};
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
function isValidProfileShape(item: unknown): item is ServerProfile {
if (typeof item !== "object" || item === null) return false;
const obj = item as Record<string, unknown>;
return (
typeof obj.id === "string" &&
typeof obj.name === "string" && obj.name.length > 0 &&
typeof obj.host === "string" && obj.host.length > 0 &&
typeof obj.username === "string" && obj.username.length > 0 &&
typeof obj.name === "string" &&
obj.name.length > 0 &&
typeof obj.host === "string" &&
obj.host.length > 0 &&
typeof obj.username === "string" &&
typeof obj.color === "string" &&
typeof obj.autoConnect === "boolean"
typeof obj.autoConnect === "boolean" &&
(obj.lastConnected === null || typeof obj.lastConnected === "string")
);
}
function loadProfiles(backend: StorageBackend): ServerProfile[] {
const raw = backend.get(STORAGE_KEY);
if (raw === null) {
return [];
}
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter(isValidProfileShape);
} catch {
return [];
}
function isValidStoredData(data: unknown): data is StoredData {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
return (
typeof obj.schemaVersion === "number" &&
Array.isArray(obj.profiles) &&
obj.profiles.every(isValidProfileShape)
);
}
function saveProfiles(
backend: StorageBackend,
profiles: readonly ServerProfile[],
): void {
backend.set(STORAGE_KEY, JSON.stringify(profiles));
// ---------------------------------------------------------------------------
// Default Tauri persistence backend
// ---------------------------------------------------------------------------
export function createTauriBackend(): PersistenceBackend {
return {
async load(): Promise<StoredData | null> {
const { invoke } = await import("@tauri-apps/api/core");
const settings = (await invoke("get_settings")) as Record<
string,
unknown
>;
const raw = settings[STORAGE_KEY];
if (raw === undefined || raw === null) return null;
if (isValidStoredData(raw)) return raw;
return null;
},
async save(data: StoredData): Promise<void> {
const { invoke } = await import("@tauri-apps/api/core");
await invoke("save_settings", { key: STORAGE_KEY, value: data });
},
};
}
// ---------------------------------------------------------------------------
// Profile Manager
// ---------------------------------------------------------------------------
export interface ProfileManager {
/** Reactive store — subscribe for state changes. */
readonly store: Store<ProfilesState>;
/** Load profiles from persistence backend into store. */
loadProfiles(): Promise<void>;
/** Save current profiles to persistence backend. */
saveProfiles(): Promise<void>;
/** Get all profiles (snapshot). */
getAll(): readonly ServerProfile[];
/** Get a profile by id. */
getById(id: string): ServerProfile | null;
/** Add a new profile. Returns the created profile. */
addProfile(data: CreateProfileData): ServerProfile;
/** Update an existing profile. Returns updated profile or null if not found. */
updateProfile(id: string, data: UpdateProfileData): ServerProfile | null;
/** Remove a profile by id. Returns true if removed. */
removeProfile(id: string): boolean;
/** Returns the first profile with autoConnect=true, or null. */
getAutoConnectProfile(): ServerProfile | null;
/** Set lastConnected to current ISO timestamp. */
setLastConnected(id: string): void;
/** Check health of a single profile by id. Updates healthStatuses. */
checkHealth(profileId: string): Promise<HealthStatus>;
/** Check health of all profiles in parallel. Updates healthStatuses. */
checkAllHealth(): Promise<ReadonlyMap<string, HealthStatus>>;
/** Export all profiles as a JSON string. */
exportProfiles(): string;
/** Import profiles from JSON string. Merges by host (skips duplicates). */
importProfiles(json: string): { imported: number; skipped: number };
}
export function createProfileManager(
backend: StorageBackend = localStorageBackend,
backend: PersistenceBackend,
fetchFn?: FetchFn,
): ProfileManager {
let profiles: ServerProfile[] = loadProfiles(backend);
const initialState: ProfilesState = {
profiles: [],
healthStatuses: new Map(),
};
function persist(): void {
saveProfiles(backend, profiles);
const store = createStore<ProfilesState>(initialState);
// Resolve which fetch to use: injected mock, Tauri plugin, or global
const doFetch: FetchFn = fetchFn ?? (fetch as unknown as FetchFn);
// ── Helpers ────────────────────────────────────────────────
function currentProfiles(): readonly ServerProfile[] {
return store.getState().profiles;
}
return {
function setProfiles(profiles: readonly ServerProfile[]): void {
store.setState((prev) => ({
...prev,
profiles,
}));
}
function setHealthStatus(profileId: string, status: HealthStatus): void {
store.setState((prev) => {
const next = new Map(prev.healthStatuses);
next.set(profileId, status);
return { ...prev, healthStatuses: next };
});
}
function toStoredData(): StoredData {
return {
schemaVersion: CURRENT_SCHEMA_VERSION,
profiles: [...currentProfiles()],
};
}
// ── Health check implementation ────────────────────────────
async function pingHost(host: string): Promise<HealthStatus> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
const start = performance.now();
try {
const res = await doFetch(`https://${host}/api/v1/health`, {
signal: controller.signal,
});
const elapsed = Math.round(performance.now() - start);
if (!res.ok) {
return { status: "offline", latencyMs: elapsed, version: null };
}
const body = (await res.json()) as { version?: string };
const version = typeof body.version === "string" ? body.version : null;
const status = elapsed > SLOW_THRESHOLD_MS ? "slow" : "online";
return { status, latencyMs: elapsed, version };
} catch {
return { status: "offline", latencyMs: null, version: null };
} finally {
clearTimeout(timer);
}
}
// ── Public API ─────────────────────────────────────────────
const manager: ProfileManager = {
store,
async loadProfiles(): Promise<void> {
const data = await backend.load();
if (data !== null) {
setProfiles(data.profiles);
}
},
async saveProfiles(): Promise<void> {
await backend.save(toStoredData());
},
getAll(): readonly ServerProfile[] {
return [...profiles];
return [...currentProfiles()];
},
getById(id: string): ServerProfile | null {
return profiles.find((p) => p.id === id) ?? null;
return currentProfiles().find((p) => p.id === id) ?? null;
},
create(data: CreateProfileData): ServerProfile {
addProfile(data: CreateProfileData): ServerProfile {
const profile: ServerProfile = {
...data,
id: crypto.randomUUID(),
lastConnected: null,
schemaVersion: CURRENT_SCHEMA_VERSION,
};
profiles = [...profiles, profile];
persist();
setProfiles([...currentProfiles(), profile]);
return profile;
},
update(id: string, data: UpdateProfileData): ServerProfile | null {
updateProfile(id: string, data: UpdateProfileData): ServerProfile | null {
const profiles = currentProfiles();
const index = profiles.findIndex((p) => p.id === id);
if (index === -1) {
return null;
}
if (index === -1) return null;
const existing = profiles[index]!;
const updated: ServerProfile = { ...existing, ...data };
profiles = profiles.map((p) => (p.id === id ? updated : p));
persist();
setProfiles(profiles.map((p) => (p.id === id ? updated : p)));
return updated;
},
remove(id: string): boolean {
const before = profiles.length;
profiles = profiles.filter((p) => p.id !== id);
if (profiles.length === before) {
return false;
}
persist();
removeProfile(id: string): boolean {
const profiles = currentProfiles();
const filtered = profiles.filter((p) => p.id !== id);
if (filtered.length === profiles.length) return false;
setProfiles(filtered);
return true;
},
getAutoConnectProfile(): ServerProfile | null {
return currentProfiles().find((p) => p.autoConnect) ?? null;
},
setLastConnected(id: string): void {
const profiles = currentProfiles();
const index = profiles.findIndex((p) => p.id === id);
if (index === -1) {
return;
}
if (index === -1) return;
const existing = profiles[index]!;
const updated: ServerProfile = {
...existing,
lastConnected: new Date().toISOString(),
};
profiles = profiles.map((p) => (p.id === id ? updated : p));
persist();
setProfiles(profiles.map((p) => (p.id === id ? updated : p)));
},
getAutoConnect(): ServerProfile | null {
return profiles.find((p) => p.autoConnect) ?? null;
async checkHealth(profileId: string): Promise<HealthStatus> {
const profile = currentProfiles().find((p) => p.id === profileId);
if (!profile) {
const offline: HealthStatus = {
status: "offline",
latencyMs: null,
version: null,
};
return offline;
}
setHealthStatus(profileId, {
status: "checking",
latencyMs: null,
version: null,
});
const result = await pingHost(profile.host);
setHealthStatus(profileId, result);
return result;
},
async checkAllHealth(): Promise<ReadonlyMap<string, HealthStatus>> {
const profiles = currentProfiles();
// Set all to "checking" first
for (const profile of profiles) {
setHealthStatus(profile.id, {
status: "checking",
latencyMs: null,
version: null,
});
}
// Ping all in parallel
const results = await Promise.all(
profiles.map(async (profile) => {
const result = await pingHost(profile.host);
setHealthStatus(profile.id, result);
return [profile.id, result] as const;
}),
);
return new Map(results);
},
exportProfiles(): string {
return JSON.stringify(profiles);
return JSON.stringify(toStoredData());
},
importProfiles(json: string): { imported: number; skipped: number } {
let incoming: unknown;
let parsed: unknown;
try {
incoming = JSON.parse(json);
parsed = JSON.parse(json);
} catch {
return { imported: 0, skipped: 0 };
}
if (!Array.isArray(incoming)) {
// Accept either StoredData envelope or a bare array
let incoming: unknown[];
if (isValidStoredData(parsed)) {
incoming = [...parsed.profiles];
} else if (Array.isArray(parsed)) {
incoming = parsed;
} else {
return { imported: 0, skipped: 0 };
}
const existingHosts = new Set(profiles.map((p) => p.host));
const existingHosts = new Set(currentProfiles().map((p) => p.host));
let imported = 0;
let skipped = 0;
const newProfiles: ServerProfile[] = [];
for (const raw of incoming) {
if (!isValidProfileShape(raw)) {
@@ -195,31 +394,27 @@ export function createProfileManager(
skipped++;
} else {
const profile: ServerProfile = {
id: crypto.randomUUID(),
name: raw.name,
host: raw.host,
username: raw.username,
color: raw.color,
autoConnect: raw.autoConnect,
lastConnected: null,
id: crypto.randomUUID(),
schemaVersion: CURRENT_SCHEMA_VERSION,
};
profiles = [...profiles, profile];
newProfiles.push(profile);
existingHosts.add(profile.host);
imported++;
}
}
if (imported > 0) {
persist();
setProfiles([...currentProfiles(), ...newProfiles]);
}
return { imported, skipped };
},
migrate(): void {
// Currently a no-op for schema version 1.
// Future migrations will go here.
},
};
return manager;
}
+122 -53
View File
@@ -1,104 +1,119 @@
/**
* Per-key rate limiter with sliding window tracking.
* Window-based rate limiter with per-key tracking.
*
* Internally stores an array of timestamps per key.
* Expired entries are cleaned on every public method call.
* Uses a sliding window algorithm: each key stores an array of timestamps.
* Expired entries are pruned on every public call. No external dependencies.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface RateLimiterConfig {
/** Maximum number of actions allowed per window. */
readonly maxTokens: number;
/** Window duration in milliseconds. */
readonly windowMs: number;
}
interface KeyState {
readonly timestamps: readonly number[];
}
// ---------------------------------------------------------------------------
// Default key used when callers omit the key argument
// ---------------------------------------------------------------------------
const DEFAULT_KEY = "__default__" as const;
// ---------------------------------------------------------------------------
// RateLimiter
// ---------------------------------------------------------------------------
export class RateLimiter {
private readonly maxRequests: number;
private readonly windowMs: number;
private readonly config: Readonly<RateLimiterConfig>;
private state: ReadonlyMap<string, KeyState>;
constructor(maxRequests: number, windowMs: number) {
if (maxRequests < 1) {
throw new Error('maxRequests must be >= 1');
constructor(config: RateLimiterConfig) {
if (config.maxTokens < 1) {
throw new Error("maxTokens must be >= 1");
}
if (windowMs < 1) {
throw new Error('windowMs must be >= 1');
if (config.windowMs < 1) {
throw new Error("windowMs must be >= 1");
}
this.maxRequests = maxRequests;
this.windowMs = windowMs;
this.config = Object.freeze({ ...config });
this.state = new Map();
}
/**
* Attempt to consume one request for the given key.
* Returns true if the request is allowed, false if rate-limited.
* Attempt to consume one token for the given key.
* Returns `true` if the action is allowed, `false` if rate-limited.
*/
tryConsume(key: string): boolean {
tryConsume(key?: string): boolean {
const k = key ?? DEFAULT_KEY;
const now = Date.now();
const cleaned = this.cleanupAll(now);
const entry = cleaned.get(key);
const cleaned = this.pruneAll(now);
const entry = cleaned.get(k);
const timestamps = entry?.timestamps ?? [];
if (timestamps.length >= this.maxRequests) {
if (timestamps.length >= this.config.maxTokens) {
this.state = cleaned;
return false;
}
const newEntry: KeyState = {
timestamps: [...timestamps, now],
};
const newEntry: KeyState = { timestamps: [...timestamps, now] };
const next = new Map(cleaned);
next.set(key, newEntry);
next.set(k, Object.freeze(newEntry));
this.state = next;
return true;
}
/**
* Reset rate limit state for a specific key, or all keys if none provided.
*/
/** Reset state for a single key (or the default key when omitted). */
reset(key?: string): void {
if (key === undefined) {
this.state = new Map();
return;
}
const k = key ?? DEFAULT_KEY;
const next = new Map(this.state);
next.delete(key);
next.delete(k);
this.state = next;
}
/** Clear all tracked state across every key. */
resetAll(): void {
this.state = new Map();
}
/**
* Returns milliseconds until the next request would be allowed for the key.
* Returns 0 if a request is allowed right now.
*/
getRemainingMs(key: string): number {
getRemainingMs(key?: string): number {
const k = key ?? DEFAULT_KEY;
const now = Date.now();
const cleaned = this.cleanupAll(now);
const cleaned = this.pruneAll(now);
this.state = cleaned;
const entry = cleaned.get(key);
const entry = cleaned.get(k);
const timestamps = entry?.timestamps ?? [];
if (timestamps.length < this.maxRequests) {
if (timestamps.length < this.config.maxTokens) {
return 0;
}
// The oldest timestamp in the window determines when the next slot opens
const oldest = timestamps[0];
if (oldest === undefined) {
return 0;
}
const remaining = oldest + this.windowMs - now;
return Math.max(0, remaining);
return Math.max(0, oldest + this.config.windowMs - now);
}
/**
* Return a new map with expired timestamps removed from every key.
*/
private cleanupAll(now: number): ReadonlyMap<string, KeyState> {
const cutoff = now - this.windowMs;
/** Return a new map with expired timestamps removed from every key. */
private pruneAll(now: number): ReadonlyMap<string, KeyState> {
const cutoff = now - this.config.windowMs;
const next = new Map<string, KeyState>();
for (const [key, entry] of this.state) {
const filtered = entry.timestamps.filter((t) => t > cutoff);
if (filtered.length > 0) {
next.set(key, { timestamps: filtered });
next.set(key, Object.freeze({ timestamps: filtered }));
}
}
@@ -106,9 +121,63 @@ export class RateLimiter {
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Pre-configured rate limiters matching PROTOCOL.md limits.
* Create a `RateLimiter` from explicit config values.
*
* @param maxTokens Maximum actions per window.
* @param windowMs Window length in milliseconds.
*/
export function createRateLimiter(maxTokens: number, windowMs: number): RateLimiter {
return new RateLimiter({ maxTokens, windowMs });
}
// ---------------------------------------------------------------------------
// Pre-configured limiters (PROTOCOL.md - Rate Limits)
// ---------------------------------------------------------------------------
/** Chat messages: 10 per second. */
export function createChatLimiter(): RateLimiter {
return createRateLimiter(10, 1_000);
}
/** Typing events: 1 per 3 seconds (use channel id as key). */
export function createTypingLimiter(): RateLimiter {
return createRateLimiter(1, 3_000);
}
/** Presence updates: 1 per 10 seconds. */
export function createPresenceLimiter(): RateLimiter {
return createRateLimiter(1, 10_000);
}
/** Reactions: 5 per second. */
export function createReactionLimiter(): RateLimiter {
return createRateLimiter(5, 1_000);
}
/** Voice signaling: 20 per second. */
export function createVoiceLimiter(): RateLimiter {
return createRateLimiter(20, 1_000);
}
/** Voice camera / screenshare toggle: 2 per second. */
export function createVideoCameraLimiter(): RateLimiter {
return createRateLimiter(2, 1_000);
}
/** Soundboard: 1 per 3 seconds. */
export function createSoundboardLimiter(): RateLimiter {
return createRateLimiter(1, 3_000);
}
// ---------------------------------------------------------------------------
// Bundled set of all protocol limiters
// ---------------------------------------------------------------------------
export interface RateLimiterSet {
readonly chat: RateLimiter;
readonly typing: RateLimiter;
@@ -120,13 +189,13 @@ export interface RateLimiterSet {
}
export function createRateLimiterSet(): RateLimiterSet {
return {
chat: new RateLimiter(10, 1_000),
typing: new RateLimiter(1, 3_000),
presence: new RateLimiter(1, 10_000),
reactions: new RateLimiter(5, 1_000),
voice: new RateLimiter(20, 1_000),
voiceVideo: new RateLimiter(2, 1_000),
soundboard: new RateLimiter(1, 3_000),
};
return Object.freeze({
chat: createChatLimiter(),
typing: createTypingLimiter(),
presence: createPresenceLimiter(),
reactions: createReactionLimiter(),
voice: createVoiceLimiter(),
voiceVideo: createVideoCameraLimiter(),
soundboard: createSoundboardLimiter(),
});
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Window state persistence — saves/restores window position and size.
* Uses Tauri IPC commands backed by tauri-plugin-store.
*/
import { createLogger } from "./logger";
const log = createLogger("window-state");
export interface WindowState {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
readonly maximized: boolean;
}
const STORAGE_KEY = "windowState";
const SAVE_DEBOUNCE_MS = 500;
async function getInvoke(): Promise<
((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null
> {
try {
const { invoke } = await import("@tauri-apps/api/core");
return invoke;
} catch {
return null;
}
}
/**
* Save the current window state to the Tauri settings store.
*/
async function saveState(state: WindowState): Promise<void> {
const invoke = await getInvoke();
if (!invoke) return;
try {
await invoke("save_settings", { key: STORAGE_KEY, value: state });
} catch (err) {
log.error("Failed to save window state", { error: String(err) });
}
}
/**
* Load the previously saved window state.
*/
async function loadState(): Promise<WindowState | null> {
const invoke = await getInvoke();
if (!invoke) return null;
try {
const all = (await invoke("get_settings")) as Record<string, unknown>;
const raw = all[STORAGE_KEY];
if (raw && typeof raw === "object") {
const s = raw as Record<string, unknown>;
if (
typeof s.x === "number" &&
typeof s.y === "number" &&
typeof s.width === "number" &&
typeof s.height === "number" &&
typeof s.maximized === "boolean"
) {
return {
x: s.x,
y: s.y,
width: s.width,
height: s.height,
maximized: s.maximized,
};
}
}
return null;
} catch (err) {
log.error("Failed to load window state", { error: String(err) });
return null;
}
}
/**
* Initialize window state persistence.
* Restores saved position/size on startup and listens for changes.
* Returns a cleanup function.
*/
export async function initWindowState(): Promise<() => void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let tauriWindow: any;
try {
tauriWindow = await import("@tauri-apps/api/window");
} catch {
return () => {};
}
const win = tauriWindow.getCurrentWindow();
const cleanups: Array<() => void> = [];
// Restore saved state
const saved = await loadState();
if (saved !== null) {
try {
if (saved.maximized) {
await win.maximize();
} else {
const pos = new tauriWindow.PhysicalPosition(saved.x, saved.y);
const size = new tauriWindow.PhysicalSize(saved.width, saved.height);
await win.setPosition(pos);
await win.setSize(size);
}
log.info("Restored window state", { x: saved.x, y: saved.y, width: saved.width, height: saved.height });
} catch (err) {
log.warn("Failed to restore window state", { error: String(err) });
}
}
// Debounced save on move/resize
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function debouncedSave(): void {
if (saveTimer !== null) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(() => {
void (async () => {
try {
const pos = await win.outerPosition();
const size = await win.outerSize();
const maximized = await win.isMaximized();
await saveState({
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
maximized,
});
} catch {
// Window may have been closed during save
}
})();
}, SAVE_DEBOUNCE_MS);
}
try {
const unlistenMoved = await win.onMoved(() => debouncedSave());
cleanups.push(unlistenMoved);
} catch {
// onMoved may not be available
}
try {
const unlistenResized = await win.onResized(() => debouncedSave());
cleanups.push(unlistenResized);
} catch {
// onResized may not be available
}
return () => {
if (saveTimer !== null) {
clearTimeout(saveTimer);
}
for (const cleanup of cleanups) {
cleanup();
}
};
}
+96 -26
View File
@@ -8,7 +8,11 @@ import { wireDispatcher } from "@lib/dispatcher";
import { authStore, setAuth, clearAuth } from "@stores/auth.store";
import { createConnectPage } from "@pages/ConnectPage";
import { createMainPage } from "@pages/MainPage";
import { createConnectedOverlay } from "@components/ConnectedOverlay";
import type { ConnectedOverlayControl } from "@components/ConnectedOverlay";
import { createLogger } from "@lib/logger";
import { saveCredential, deleteCredential } from "@lib/credentials";
import { initWindowState } from "@lib/window-state";
const log = createLogger("main");
@@ -25,6 +29,7 @@ const router = createRouter("connect");
const api = createApiClient({ host: "" });
const ws = createWsClient();
let dispatcherCleanup: (() => void) | null = null;
let connectedOverlay: ConnectedOverlayControl | null = null;
// Current page component reference for cleanup
let currentPage: { destroy?(): void } | null = null;
@@ -36,52 +41,108 @@ function renderPage(pageId: "connect" | "main"): void {
currentPage = null;
appEl!.textContent = "";
// Shared helper for post-auth WS connect + overlay flow
function wirePostAuth(host: string, token: string, username: string): void {
api.setConfig({ token });
ws.connect({ host, token });
dispatcherCleanup = wireDispatcher(ws);
// Save credential for auto-reconnect (fire-and-forget)
void saveCredential(host, username, token);
const unsubState = ws.onStateChange((wsState) => {
if (wsState === "connected") {
unsubState();
const auth = authStore.getState();
connectedOverlay = createConnectedOverlay({
serverName: auth.serverName ?? host,
username: auth.user?.username ?? username,
motd: auth.motd ?? "",
onReady: () => {
connectedOverlay?.destroy();
connectedOverlay = null;
router.navigate("main");
},
});
appEl!.appendChild(connectedOverlay.element);
connectedOverlay.show();
const unsubReady = ws.on("ready", () => {
unsubReady();
connectedOverlay?.markReady();
});
}
});
}
// Track partial auth state for TOTP flow
let pendingTotpHost = "";
let pendingTotpPartialToken = "";
let pendingTotpUsername = "";
if (pageId === "connect") {
const connectPage = createConnectPage({
async onLogin(host, username, password) {
api.setConfig({ host });
const result = await api.login(username, password);
if (result.requires_2fa) {
// TODO: Wire TOTP form state transition
log.info("2FA required — TOTP flow not yet wired");
pendingTotpHost = host;
pendingTotpPartialToken = result.partial_token ?? "";
pendingTotpUsername = username;
connectPage.showTotp();
return;
}
if (result.token) {
api.setConfig({ token: result.token });
ws.connect({ host, token: result.token });
dispatcherCleanup = wireDispatcher(ws);
// Wait for auth_ok from WS
const unsub = ws.onStateChange((state) => {
if (state === "connected") {
unsub();
router.navigate("main");
}
});
wirePostAuth(host, result.token, username);
}
},
async onRegister(host, username, password, inviteCode) {
api.setConfig({ host });
const result = await api.register(username, password, inviteCode);
api.setConfig({ token: result.token });
ws.connect({ host, token: result.token });
dispatcherCleanup = wireDispatcher(ws);
const unsub = ws.onStateChange((state) => {
if (state === "connected") {
unsub();
router.navigate("main");
}
});
wirePostAuth(host, result.token, username);
},
async onTotpSubmit(_code) {
// TODO: implement TOTP verification
log.info("TOTP submit — not yet wired");
async onTotpSubmit(code) {
if (!pendingTotpPartialToken) {
log.error("TOTP submit without pending partial token");
return;
}
const result = await api.verifyTotp(code, pendingTotpPartialToken);
if (result.token) {
wirePostAuth(pendingTotpHost, result.token, pendingTotpUsername);
}
},
});
safeMount(connectPage, appEl!);
currentPage = connectPage;
// Kick off health checks for default profiles in background
void (async () => {
const profiles = [{ host: "localhost:8443", name: "Local Server" }];
for (const profile of profiles) {
try {
connectPage.updateHealthStatus(profile.host, {
status: "checking",
latencyMs: null,
version: null,
});
const start = performance.now();
const health = await api.getHealth(profile.host, 3000);
const elapsed = Math.round(performance.now() - start);
connectPage.updateHealthStatus(profile.host, {
status: elapsed > 1500 ? "slow" : "online",
latencyMs: elapsed,
version: health.version,
});
} catch {
connectPage.updateHealthStatus(profile.host, {
status: "offline",
latencyMs: null,
version: null,
});
}
}
})();
} else {
const mainPage = createMainPage({ ws, api });
safeMount(mainPage, appEl!);
@@ -98,10 +159,19 @@ authStore.subscribe((state) => {
dispatcherCleanup?.();
dispatcherCleanup = null;
ws.disconnect();
// Clear stored credential on logout
const host = api.getConfig().host;
if (host) {
void deleteCredential(host);
}
router.navigate("connect");
}
});
// Initial render
renderPage(router.getCurrentPage());
// Initialize window state persistence (fire-and-forget)
void initWindowState();
log.info("OwnCord client initialized");
+33 -1
View File
@@ -11,6 +11,7 @@ import {
import type { MountableComponent } from "@lib/safe-render";
import { openSettings, closeSettings } from "@stores/ui.store";
import { createSettingsOverlay } from "@components/SettingsOverlay";
import type { HealthStatus } from "@lib/profiles";
// ---------------------------------------------------------------------------
// Types
@@ -80,6 +81,7 @@ export function createConnectPage(
showConnecting(): void;
showError(message: string): void;
resetToIdle(): void;
updateHealthStatus(host: string, status: HealthStatus): void;
} {
// --- internal state (mutable, local to this instance) ---
let formState: FormState = "idle";
@@ -149,8 +151,12 @@ export function createConnectPage(
return panel;
}
// Map of host -> DOM elements for health status updates
const healthElements = new Map<string, { dot: HTMLDivElement; latency: HTMLSpanElement }>();
function renderServerProfiles(profiles: readonly ServerProfile[]): void {
clearChildren(serverListEl);
healthElements.clear();
for (const profile of profiles) {
const item = createElement("div", {
class: "server-item",
@@ -163,13 +169,20 @@ export function createConnectPage(
});
setText(icon, getIconInitials(profile.name));
// Health status dot on the icon
const statusDot = createElement("div", { class: "srv-status-dot unknown" });
icon.appendChild(statusDot);
const info = createElement("div", { class: "srv-info" });
const name = createElement("div", { class: "srv-name" }, profile.name);
const meta = createElement("div", { class: "srv-meta" });
const host = createElement("span", { class: "srv-host" }, profile.host);
meta.appendChild(host);
const latency = createElement("span", { class: "srv-latency" });
appendChildren(meta, host, latency);
appendChildren(info, name, meta);
healthElements.set(profile.host, { dot: statusDot, latency });
appendChildren(item, icon, info);
item.addEventListener(
@@ -184,6 +197,24 @@ export function createConnectPage(
}
}
function updateHealthStatus(host: string, status: HealthStatus): void {
const els = healthElements.get(host);
if (!els) return;
// Update status dot
els.dot.className = `srv-status-dot ${status.status}`;
// Update latency badge
if (status.latencyMs !== null) {
const ms = status.latencyMs;
setText(els.latency, `${ms}ms`);
els.latency.className = `srv-latency ${ms < 100 ? "good" : ms < 500 ? "warn" : "bad"}`;
} else {
setText(els.latency, "");
els.latency.className = "srv-latency";
}
}
function buildFormPanel(): HTMLDivElement {
const panel = createElement("div", { class: "form-panel" });
@@ -628,6 +659,7 @@ export function createConnectPage(
showConnecting,
showError,
resetToIdle,
updateHealthStatus,
};
}
+72 -10
View File
@@ -6,6 +6,7 @@ import type { MountableComponent } from "@lib/safe-render";
import type { WsClient } from "@lib/ws";
import type { ApiClient } from "@lib/api";
import { createLogger } from "@lib/logger";
import { createRateLimiterSet } from "@lib/rate-limiter";
import { createServerStrip } from "@components/ServerStrip";
import { createChannelSidebar } from "@components/ChannelSidebar";
import { createUserBar } from "@components/UserBar";
@@ -17,8 +18,11 @@ import type { MessageInputComponent } from "@components/MessageInput";
import { createTypingIndicator } from "@components/TypingIndicator";
import { createServerBanner } from "@components/ServerBanner";
import type { ServerBannerControl } from "@components/ServerBanner";
import { authStore } from "@stores/auth.store";
import { channelsStore, getActiveChannel } from "@stores/channels.store";
import { createSettingsOverlay } from "@components/SettingsOverlay";
import { createQuickSwitcher } from "@components/QuickSwitcher";
import { authStore, clearAuth } from "@stores/auth.store";
import { closeSettings } from "@stores/ui.store";
import { channelsStore, getActiveChannel, setActiveChannel } from "@stores/channels.store";
import {
voiceStore,
leaveVoiceChannel,
@@ -50,6 +54,8 @@ export interface MainPageOptions {
export function createMainPage(options: MainPageOptions): MountableComponent {
const { ws, api } = options;
const limiters = createRateLimiterSet();
let container: Element | null = null;
let root: HTMLDivElement | null = null;
@@ -200,10 +206,12 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
},
onReactionClick: (msgId: number, emoji: string) => {
if (emoji === "") return; // empty = open picker (future)
ws.send({
type: "reaction_add",
payload: { message_id: msgId, emoji },
});
if (limiters.reactions.tryConsume()) {
ws.send({
type: "reaction_add",
payload: { message_id: msgId, emoji },
});
}
},
});
if (messagesSlot !== null) {
@@ -237,10 +245,12 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
});
},
onTyping: () => {
ws.send({
type: "typing_start",
payload: { channel_id: channelId },
});
if (limiters.typing.tryConsume(String(channelId))) {
ws.send({
type: "typing_start",
payload: { channel_id: channelId },
});
}
},
onEditMessage: (messageId: number, content: string) => {
ws.send({
@@ -363,16 +373,19 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
leaveVoiceChannel();
},
onMuteToggle: () => {
if (!limiters.voice.tryConsume()) return;
const next = !voiceStore.getState().localMuted;
setLocalMuted(next);
ws.send({ type: "voice_mute", payload: { muted: next } });
},
onDeafenToggle: () => {
if (!limiters.voice.tryConsume()) return;
const next = !voiceStore.getState().localDeafened;
setLocalDeafened(next);
ws.send({ type: "voice_deafen", payload: { deafened: next } });
},
onCameraToggle: () => {
if (!limiters.voiceVideo.tryConsume()) return;
ws.send({ type: "voice_camera", payload: { enabled: false } });
},
onScreenshareToggle: () => {
@@ -407,6 +420,55 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
appendChildren(app, serverStripSlot, sidebarWrapper, chatArea, memberListSlot);
root.appendChild(app);
// Settings overlay (full-screen, toggled via uiStore.settingsOpen)
const settingsOverlay = createSettingsOverlay({
onClose: () => closeSettings(),
onChangePassword: async () => { /* wired when API integration is complete */ },
onUpdateProfile: async () => { /* wired when API integration is complete */ },
onLogout: () => clearAuth(),
});
settingsOverlay.mount(root);
children.push(settingsOverlay);
// Quick switcher (Ctrl+K)
let quickSwitcher: MountableComponent | null = null;
function openQuickSwitcher(): void {
if (quickSwitcher !== null || root === null) return;
quickSwitcher = createQuickSwitcher({
onSelectChannel: (channelId: number) => {
setActiveChannel(channelId);
},
onSearch: () => {},
onClose: closeQuickSwitcher,
});
quickSwitcher.mount(root);
}
function closeQuickSwitcher(): void {
if (quickSwitcher !== null) {
quickSwitcher.destroy?.();
quickSwitcher = null;
}
}
const quickSwitcherKeyHandler = (e: KeyboardEvent): void => {
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
e.preventDefault();
if (quickSwitcher !== null) {
closeQuickSwitcher();
} else {
openQuickSwitcher();
}
}
};
document.addEventListener("keydown", quickSwitcherKeyHandler);
unsubscribers.push(() => {
document.removeEventListener("keydown", quickSwitcherKeyHandler);
closeQuickSwitcher();
});
container.appendChild(root);
// --- Subscribe to channel changes ---
+17 -1
View File
@@ -11,6 +11,8 @@ import type {
VoiceConfigPayload,
VoiceSpeakersPayload,
} from "@lib/types";
import { membersStore } from "@stores/members.store";
import { authStore } from "@stores/auth.store";
export interface VoiceUser {
readonly userId: number;
@@ -59,9 +61,10 @@ export function setVoiceStates(states: readonly ReadyVoiceState[]): void {
userMap = new Map();
channelMap.set(vs.channel_id, userMap);
}
const member = membersStore.getState().members.get(vs.user_id);
userMap.set(vs.user_id, {
userId: vs.user_id,
username: "",
username: member?.username ?? "",
muted: vs.muted,
deafened: vs.deafened,
speaking: false,
@@ -70,9 +73,22 @@ export function setVoiceStates(states: readonly ReadyVoiceState[]): void {
});
}
// Check if current user is in any voice channel
const currentUserId = authStore.getState().user?.id ?? 0;
let autoJoinChannel: number | null = null;
if (currentUserId !== 0) {
for (const vs of states) {
if (vs.user_id === currentUserId) {
autoJoinChannel = vs.channel_id;
break;
}
}
}
voiceStore.setState((prev) => ({
...prev,
voiceUsers: channelMap,
currentChannelId: autoJoinChannel ?? prev.currentChannelId,
}));
}
+104
View File
@@ -0,0 +1,104 @@
# E2E Test Issues — 2026-03-15
## 135 tests: 55 passed, 80 failed (40.7%)
## Passing Files
| Spec File | Pass/Total |
| --------- | ---------- |
| `banners-toasts.spec.ts` | 4/4 |
| `chat-header.spec.ts` | 6/6 |
| `connect-page.spec.ts` | 15/17 |
| `server-strip.spec.ts` | 4/4 |
| `channel-sidebar.spec.ts` | 8/9 |
| `main-layout.spec.ts` | 4/6 |
| `user-bar.spec.ts` | 4/5 |
| `typing-indicator.spec.ts` | 2/4 |
## Failing Files
| Spec File | Pass/Total |
| --------- | ---------- |
| `message-list.spec.ts` | 0/16 |
| `message-input.spec.ts` | 0/7 |
| `settings-overlay.spec.ts` | 0/24 |
| `overlays.spec.ts` (quick switcher) | 0/9 |
| `overlays.spec.ts` (emoji picker) | 0/6 |
| `voice-widget.spec.ts` | 0/6 |
| `member-list.spec.ts` | 0/7 |
## Root Causes (fix in this order)
### 1. CRITICAL — No channel auto-selected on login (~35 tests)
First channel lacks `.active` after login, so messages pane,
input, and typing bar never mount. Cascades into
`message-list`, `message-input`, `overlays` (emoji),
and `typing-indicator`.
**Affected:** `message-list` (16), `message-input` (7),
`overlays` emoji (6), `typing-indicator` (2),
`main-layout` (2), `channel-sidebar` (1)
**Fix:** Check why first channel isn't auto-selected
after `ready` WS payload. Likely store or MainPage
doesn't call `setActiveChannel` on initial render.
Mock may need correct channel data format.
### 2. HIGH — Settings overlay toggle broken (24 tests)
Gear button click doesn't add `.open` to
`.settings-overlay`. Button IS found (user-bar
tests pass), so handler or class toggle is broken.
**Affected:** All 24 tests in `settings-overlay.spec.ts`
**Fix:** Check if gear calls `openSettings()` from
`ui.store` and if SettingsOverlay subscribes to
toggle `.open`. May be a wiring issue in MainPage.
### 3. MEDIUM — Quick Switcher Ctrl+K not wired (9 tests)
`Ctrl+K` doesn't open `.quick-switcher-overlay`.
Possible Tauri global shortcut vs DOM `keydown`
conflict — Tauri shortcuts don't work in E2E.
**Affected:** All 9 quick switcher tests in `overlays.spec.ts`
**Fix:** Check if it uses Tauri global shortcut vs
DOM `keydown`. If global, add DOM fallback or mock
the shortcut trigger.
### 4. MEDIUM — Voice widget stays hidden (6 tests)
`.voice-widget` exists in DOM but `.visible` is
never applied after mock `voice_states` injection.
**Affected:** All 6 tests in `voice-widget.spec.ts`
**Fix:** Check if voice store processes
`voice_state_update` WS messages and if widget
subscribes to toggle `.visible`. Mock may need
a different message type.
### 5. MEDIUM — Member list not rendering members (7 tests)
`.member-role-group` count is 0 despite members in
mock ready payload. Panel is mounted but empty.
**Affected:** All 7 tests in `member-list.spec.ts`
**Fix:** Check if members store populates from
`ready` payload and if MemberList subscribes.
Verify `.member-role-group` selector matches
actual component output.
### 6. LOW — `.status-dot` selector mismatch (1 test)
`.user-bar .status-dot` not found. Element either
doesn't exist or uses a different class name.
**Affected:** 1 test in `user-bar.spec.ts`
**Fix:** Read UserBar component source and find
the correct selector for the status indicator.
@@ -0,0 +1,54 @@
import { test, expect } from "@playwright/test";
import { mockTauriFullSession, navigateToMainPage, emitWsEvent } from "./helpers";
// ---------------------------------------------------------------------------
// Tests: Server Banner (reconnection)
// ---------------------------------------------------------------------------
test.describe("Server Banner", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("reconnecting banner is hidden by default", async ({ page }) => {
const banner = page.locator(".reconnecting-banner");
if (await banner.count() > 0) {
await expect(banner).not.toHaveClass(/visible/);
}
});
test("banner appears on WS disconnect", async ({ page }) => {
// Simulate WebSocket disconnection
await emitWsEvent(page, "ws-state", "closed");
const banner = page.locator(".reconnecting-banner.visible");
await expect(banner).toBeVisible({ timeout: 5_000 });
});
test("banner shows reconnecting text", async ({ page }) => {
await emitWsEvent(page, "ws-state", "closed");
const banner = page.locator(".reconnecting-banner.visible");
await expect(banner).toBeVisible({ timeout: 5_000 });
const text = await banner.textContent();
expect(text).toMatch(/reconnect/i);
});
test("banner disappears on WS reconnect", async ({ page }) => {
// Disconnect
await emitWsEvent(page, "ws-state", "closed");
const banner = page.locator(".reconnecting-banner.visible");
await expect(banner).toBeVisible({ timeout: 5_000 });
// Reconnect
await emitWsEvent(page, "ws-state", "open");
await page.waitForTimeout(500);
// Banner should hide
const hiddenBanner = page.locator(".reconnecting-banner");
await expect(hiddenBanner).not.toHaveClass(/visible/, { timeout: 5_000 });
});
});
@@ -0,0 +1,90 @@
import { test, expect } from "@playwright/test";
import { mockTauriFullSession, mockTauriFullSessionWithMessages, navigateToMainPage } from "./helpers";
// ---------------------------------------------------------------------------
// Tests: Channel Sidebar
// ---------------------------------------------------------------------------
test.describe("Channel Sidebar", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("sidebar is visible after login", async ({ page }) => {
const sidebar = page.locator(".channel-sidebar");
await expect(sidebar).toBeVisible();
});
test("sidebar header shows server name", async ({ page }) => {
const header = page.locator(".channel-sidebar-header h2");
await expect(header).toBeVisible();
await expect(header).toHaveText("Test Server");
});
test("channel list shows channels", async ({ page }) => {
const channelList = page.locator(".channel-list");
await expect(channelList).toBeVisible();
const channels = page.locator(".channel-item");
const count = await channels.count();
expect(count).toBeGreaterThanOrEqual(1);
});
test("channel items display channel name", async ({ page }) => {
const firstChannel = page.locator(".channel-item").first();
await expect(firstChannel).toBeVisible();
const name = firstChannel.locator(".ch-name");
await expect(name).toBeVisible();
});
test("channel items have hash icon", async ({ page }) => {
const firstChannel = page.locator(".channel-item").first();
const icon = firstChannel.locator(".ch-icon");
await expect(icon).toBeVisible();
});
test("clicking a channel marks it as active", async ({ page }) => {
const channels = page.locator(".channel-item");
const count = await channels.count();
if (count < 2) return;
const secondChannel = channels.nth(1);
await secondChannel.click();
await expect(secondChannel).toHaveClass(/active/);
});
test("clicking a channel updates chat header", async ({ page }) => {
const channels = page.locator(".channel-item");
const count = await channels.count();
if (count < 2) return;
const secondChannel = channels.nth(1);
const channelName = await secondChannel.locator(".ch-name").textContent();
await secondChannel.click();
const headerName = page.locator(".chat-header .ch-name");
await expect(headerName).toHaveText(channelName ?? "");
});
test("first channel is active by default", async ({ page }) => {
const firstChannel = page.locator(".channel-item").first();
await expect(firstChannel).toHaveClass(/active/);
});
});
test.describe("Channel Sidebar — Categories", () => {
test("categories with multiple channel types show correctly", async ({ page }) => {
await mockTauriFullSessionWithMessages(page);
await page.goto("/");
await navigateToMainPage(page);
const categories = page.locator(".category");
const count = await categories.count();
expect(count).toBeGreaterThanOrEqual(1);
});
});
@@ -0,0 +1,45 @@
import { test, expect } from "@playwright/test";
import { mockTauriFullSession, navigateToMainPage } from "./helpers";
// ---------------------------------------------------------------------------
// Tests: Chat Header
// ---------------------------------------------------------------------------
test.describe("Chat Header", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("chat header is visible", async ({ page }) => {
const header = page.locator(".chat-header");
await expect(header).toBeVisible();
});
test("chat header shows hash icon", async ({ page }) => {
const hash = page.locator(".chat-header .ch-hash");
await expect(hash).toBeVisible();
});
test("chat header shows channel name", async ({ page }) => {
const name = page.locator(".chat-header .ch-name");
await expect(name).toBeVisible();
await expect(name).not.toBeEmpty();
});
test("chat header shows topic", async ({ page }) => {
const topic = page.locator(".chat-header .ch-topic");
await expect(topic).toBeAttached();
});
test("chat header has tools area", async ({ page }) => {
const tools = page.locator(".ch-tools");
await expect(tools).toBeVisible();
});
test("chat header has search input", async ({ page }) => {
const search = page.locator(".ch-tools .search-input");
await expect(search).toBeAttached();
});
});
@@ -0,0 +1,242 @@
import { test, expect } from "@playwright/test";
import {
mockTauriConnect,
mockTauriConnectWith2FA,
mockTauriLoginError,
mockTauriFullSession,
submitLogin,
} from "./helpers";
// ---------------------------------------------------------------------------
// Tests: Connect Page — core
// ---------------------------------------------------------------------------
test.describe("Connect Page", () => {
test.beforeEach(async ({ page }) => {
await mockTauriConnect(page);
await page.goto("/");
});
test("page loads and shows the connect page", async ({ page }) => {
const connectPage = page.locator(".connect-page");
await expect(connectPage).toBeVisible();
});
test("server profile list is visible", async ({ page }) => {
const serverList = page.locator(".server-list");
await expect(serverList).toBeVisible();
const serverItem = page.locator(".server-item").first();
await expect(serverItem).toBeVisible();
await expect(serverItem.locator(".srv-name")).toHaveText("Local Server");
await expect(serverItem.locator(".srv-host")).toHaveText("localhost:8443");
});
test("login form has host, username, password fields", async ({ page }) => {
const hostInput = page.locator("#host");
const usernameInput = page.locator("#username");
const passwordInput = page.locator("#password");
await expect(hostInput).toBeVisible();
await expect(usernameInput).toBeVisible();
await expect(passwordInput).toBeVisible();
await expect(hostInput).toHaveAttribute("placeholder", "localhost:8443");
await expect(passwordInput).toHaveAttribute("type", "password");
});
test("form validation shows error for empty fields", async ({ page }) => {
const hostInput = page.locator("#host");
await hostInput.fill("");
const submitBtn = page.locator("button.btn-primary[type='submit']");
await submitBtn.click();
const errorBanner = page.locator(".error-banner.visible");
await expect(errorBanner).toBeVisible();
await expect(errorBanner).toHaveText(/required/i);
});
test("login/register toggle switches form mode", async ({ page }) => {
const toggleLink = page.locator(".form-switch a");
await expect(toggleLink).toHaveText(/Register/);
await toggleLink.click();
await expect(toggleLink).toHaveText(/Login/);
const inviteInput = page.locator("#invite");
await expect(inviteInput).toBeVisible();
const submitBtnText = page.locator("button.btn-primary .btn-text");
await expect(submitBtnText).toHaveText("Register");
await toggleLink.click();
await expect(toggleLink).toHaveText(/Register/);
await expect(submitBtnText).toHaveText("Login");
});
test("clicking server profile auto-fills host field", async ({ page }) => {
const serverItem = page.locator(".server-item").first();
await serverItem.click();
const hostInput = page.locator("#host");
await expect(hostInput).toHaveValue("localhost:8443");
});
test("password toggle button shows/hides password", async ({ page }) => {
const passwordInput = page.locator("#password");
await passwordInput.fill("secret123");
await expect(passwordInput).toHaveAttribute("type", "password");
const toggleBtn = page.locator(".password-toggle");
await toggleBtn.click();
await expect(passwordInput).toHaveAttribute("type", "text");
await toggleBtn.click();
await expect(passwordInput).toHaveAttribute("type", "password");
});
test("form shows loading state on submit", async ({ page }) => {
await page.locator("#host").fill("localhost:8443");
await page.locator("#username").fill("testuser");
await page.locator("#password").fill("password123");
const submitBtn = page.locator("button.btn-primary[type='submit']");
await submitBtn.click();
// Button should show loading state (spinner visible or loading class)
const spinner = page.locator("button.btn-primary .spinner");
await expect(spinner).toBeAttached();
});
test("settings gear button is visible", async ({ page }) => {
const settingsGear = page.locator(".settings-gear");
await expect(settingsGear).toBeVisible();
});
test("server panel header displays Servers title", async ({ page }) => {
const header = page.locator(".server-panel-header");
await expect(header).toBeVisible();
});
test("form logo shows OwnCord branding", async ({ page }) => {
const logo = page.locator(".form-logo");
await expect(logo).toBeVisible();
const logoMark = page.locator(".form-logo-mark");
await expect(logoMark).toBeVisible();
});
test("status bar exists at bottom of form", async ({ page }) => {
const statusBar = page.locator(".status-bar");
await expect(statusBar).toBeAttached();
});
});
// ---------------------------------------------------------------------------
// Tests: Login Error
// ---------------------------------------------------------------------------
test.describe("Connect Page — Login Error", () => {
test("shows error banner on failed login", async ({ page }) => {
await mockTauriLoginError(page);
await page.goto("/");
await page.locator("#host").fill("localhost:8443");
await page.locator("#username").fill("testuser");
await page.locator("#password").fill("wrongpassword");
await page.locator("button.btn-primary[type='submit']").click();
const errorBanner = page.locator(".error-banner.visible");
await expect(errorBanner).toBeVisible({ timeout: 10_000 });
});
});
// ---------------------------------------------------------------------------
// Tests: TOTP Flow
// ---------------------------------------------------------------------------
test.describe("Connect Page — TOTP", () => {
test("TOTP overlay appears when login requires 2FA", async ({ page }) => {
await mockTauriConnectWith2FA(page);
await page.goto("/");
const totpOverlay = page.locator(".totp-overlay");
await expect(totpOverlay).toHaveClass(/totp-overlay--hidden/);
await page.locator("#host").fill("localhost:8443");
await page.locator("#username").fill("testuser");
await page.locator("#password").fill("password123");
await page.locator("button.btn-primary[type='submit']").click();
await expect(totpOverlay).not.toHaveClass(/totp-overlay--hidden/, {
timeout: 10_000,
});
const totpInput = totpOverlay.locator("input[inputmode='numeric']");
await expect(totpInput).toBeVisible();
const verifyBtn = totpOverlay.locator("button.btn-primary");
await expect(verifyBtn).toHaveText("Verify");
});
test("TOTP back button cancels 2FA flow", async ({ page }) => {
await mockTauriConnectWith2FA(page);
await page.goto("/");
await page.locator("#host").fill("localhost:8443");
await page.locator("#username").fill("testuser");
await page.locator("#password").fill("password123");
await page.locator("button.btn-primary[type='submit']").click();
const totpOverlay = page.locator(".totp-overlay");
await expect(totpOverlay).not.toHaveClass(/totp-overlay--hidden/, {
timeout: 10_000,
});
const backBtn = totpOverlay.locator(".totp-back");
await backBtn.click();
await expect(totpOverlay).toHaveClass(/totp-overlay--hidden/);
});
test("TOTP overlay shows title and subtitle", async ({ page }) => {
await mockTauriConnectWith2FA(page);
await page.goto("/");
await page.locator("#host").fill("localhost:8443");
await page.locator("#username").fill("testuser");
await page.locator("#password").fill("password123");
await page.locator("button.btn-primary[type='submit']").click();
const totpOverlay = page.locator(".totp-overlay");
await expect(totpOverlay).not.toHaveClass(/totp-overlay--hidden/, {
timeout: 10_000,
});
const title = totpOverlay.locator(".totp-title");
await expect(title).toBeVisible();
const subtitle = totpOverlay.locator(".totp-subtitle");
await expect(subtitle).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Tests: Full Login → Connected Overlay
// ---------------------------------------------------------------------------
test.describe("Connect Page — Login Success", () => {
test("after login, connected overlay appears then main page renders", async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await submitLogin(page);
const overlay = page.locator(".connected-overlay");
await expect(overlay).toBeVisible({ timeout: 10_000 });
const appLayout = page.locator(".app");
await expect(appLayout).toBeVisible({ timeout: 15_000 });
});
});
+558
View File
@@ -0,0 +1,558 @@
/**
* Shared E2E test helpers — Tauri mock injection for browser-based testing.
*
* The app uses Tauri IPC through __TAURI_INTERNALS__.invoke:
* - HTTP: plugin:http|fetch → plugin:http|fetch_send → plugin:http|fetch_read_body
* - WS: ws_connect, ws_send, ws_disconnect + events ws-state, ws-message
* - Events: plugin:event|listen, plugin:event|unlisten
*/
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
// ---------------------------------------------------------------------------
// Mock data — basic
// ---------------------------------------------------------------------------
export const MOCK_TOKEN = "mock-session-token-abc123";
export const MOCK_LOGIN_RESPONSE = {
token: MOCK_TOKEN,
requires_2fa: false,
};
export const MOCK_LOGIN_2FA_RESPONSE = {
requires_2fa: true,
partial_token: "mock-partial-token",
};
export const MOCK_CHANNELS = [
{ id: 1, name: "general", type: "text", position: 0, topic: "General chat" },
{ id: 2, name: "random", type: "text", position: 1, topic: "Off-topic" },
];
export const MOCK_MESSAGES = {
messages: [
{
id: 101,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: "" },
content: "Hello world!",
timestamp: "2026-03-15T10:00:00Z",
edited_at: null,
attachments: [],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
],
has_more: false,
};
export const MOCK_READY_PAYLOAD = {
type: "ready",
payload: {
user: { id: 1, username: "testuser", avatar: "", status: "online" },
server_name: "Test Server",
motd: "Welcome to the test server",
channels: MOCK_CHANNELS,
members: [
{ id: 1, username: "testuser", avatar: "", status: "online", role: "admin" },
{ id: 2, username: "otheruser", avatar: "", status: "online", role: "member" },
],
voice_states: [],
},
};
export const MOCK_AUTH_OK = {
type: "auth_ok",
payload: {
user: { id: 1, username: "testuser", avatar: "", status: "online" },
server_name: "Test Server",
motd: "Welcome to the test server",
},
};
// ---------------------------------------------------------------------------
// Mock data — rich (for extended tests)
// ---------------------------------------------------------------------------
export const MOCK_CHANNELS_WITH_CATEGORIES = [
{ id: 1, name: "general", type: "text", position: 0, topic: "General chat", category: "Text Channels" },
{ id: 2, name: "random", type: "text", position: 1, topic: "Off-topic", category: "Text Channels" },
{ id: 3, name: "announcements", type: "text", position: 2, topic: "Important updates", category: "Information" },
{ id: 10, name: "Voice Chat", type: "voice", position: 3, topic: "", category: "Voice Channels" },
{ id: 11, name: "Music", type: "voice", position: 4, topic: "", category: "Voice Channels" },
];
export const MOCK_MEMBERS_MULTI_ROLE = [
{ id: 1, username: "testuser", avatar: "", status: "online", role: "admin" },
{ id: 2, username: "moderator1", avatar: "", status: "online", role: "moderator" },
{ id: 3, username: "member1", avatar: "", status: "idle", role: "member" },
{ id: 4, username: "member2", avatar: "", status: "dnd", role: "member" },
{ id: 5, username: "offlineuser", avatar: "", status: "offline", role: "member" },
];
export const MOCK_MESSAGES_RICH = {
messages: [
{
id: 101,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: "" },
content: "Hello world!",
timestamp: "2026-03-15T10:00:00Z",
edited_at: null,
attachments: [],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
{
id: 102,
channel_id: 1,
user: { id: 2, username: "otheruser", avatar: "" },
content: "Hey @testuser, check this out!",
timestamp: "2026-03-15T10:01:00Z",
edited_at: null,
attachments: [],
reactions: [{ emoji: "\uD83D\uDC4D", count: 2, me: true }],
reply_to: null,
pinned: false,
deleted: false,
},
{
id: 103,
channel_id: 1,
user: { id: 2, username: "otheruser", avatar: "" },
content: "```js\nconsole.log('code block');\n```",
timestamp: "2026-03-15T10:01:30Z",
edited_at: null,
attachments: [],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
{
id: 104,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: "" },
content: "Replying to your message",
timestamp: "2026-03-15T10:02:00Z",
edited_at: "2026-03-15T10:02:30Z",
attachments: [],
reactions: [],
reply_to: 102,
pinned: false,
deleted: false,
},
{
id: 105,
channel_id: 1,
user: { id: 3, username: "member1", avatar: "" },
content: "Check this image",
timestamp: "2026-03-15T10:03:00Z",
edited_at: null,
attachments: [
{ id: "1", filename: "screenshot.png", size: 102400, mime: "image/png", url: "/uploads/screenshot.png" },
],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
{
id: 106,
channel_id: 1,
user: { id: 3, username: "member1", avatar: "" },
content: "And this document",
timestamp: "2026-03-15T10:03:30Z",
edited_at: null,
attachments: [
{ id: "2", filename: "report.pdf", size: 512000, mime: "application/pdf", url: "/uploads/report.pdf" },
],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
],
has_more: true,
};
export const MOCK_VOICE_STATE = [
{ user_id: 1, channel_id: 10, muted: false, deafened: false },
{ user_id: 2, channel_id: 10, muted: true, deafened: false },
];
export const MOCK_PINNED_MESSAGES = [
{
id: 101,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: "" },
content: "Hello world!",
created_at: "2026-03-15T10:00:00Z",
pinned: true,
},
];
export const MOCK_INVITES = [
{
code: "abc123",
uses: 3,
max_uses: 10,
created_by: { id: 1, username: "testuser" },
expires_at: "2026-04-15T00:00:00Z",
},
{
code: "xyz789",
uses: 0,
max_uses: 1,
created_by: { id: 2, username: "otheruser" },
expires_at: null,
},
];
// ---------------------------------------------------------------------------
// Ready payload builders
// ---------------------------------------------------------------------------
function buildReadyPayload(overrides?: {
channels?: unknown[];
members?: unknown[];
voice_states?: unknown[];
}): unknown {
return {
type: "ready",
payload: {
user: { id: 1, username: "testuser", avatar: "", status: "online" },
server_name: "Test Server",
motd: "Welcome to the test server",
channels: overrides?.channels ?? MOCK_CHANNELS,
members: overrides?.members ?? MOCK_READY_PAYLOAD.payload.members,
voice_states: overrides?.voice_states ?? [],
},
};
}
// ---------------------------------------------------------------------------
// Tauri mock script builder
// ---------------------------------------------------------------------------
function buildTauriMockScript(opts: {
httpRoutes: Array<{ pattern: string; status: number; body: unknown }>;
simulateWsFlow: boolean;
readyOverrides?: {
channels?: unknown[];
members?: unknown[];
voice_states?: unknown[];
};
}): string {
const readyPayload = buildReadyPayload(opts.readyOverrides);
return `
// -----------------------------------------------------------------------
// Event system
// -----------------------------------------------------------------------
const __eventListeners = {};
let __callbackId = 0;
function __tauriEmitEvent(eventName, payload) {
const listeners = __eventListeners[eventName] || [];
for (const { handler } of listeners) {
try { handler({ payload, event: eventName, id: 0 }); }
catch (e) { console.error("[tauri-mock] event error", eventName, e); }
}
}
window.__tauriEmitEvent = __tauriEmitEvent;
// -----------------------------------------------------------------------
// HTTP mock state
// -----------------------------------------------------------------------
const HTTP_ROUTES = ${JSON.stringify(opts.httpRoutes)};
let __nextRid = 1;
const __pendingFetch = {}; // rid → { url, route }
const __pendingBody = {}; // responseRid → Uint8Array (body bytes)
let __bodyRead = {}; // responseRid → boolean (already read)
// Sort routes by pattern length (longest first) to match most specific route
HTTP_ROUTES.sort((a, b) => b.pattern.length - a.pattern.length);
function matchRoute(url) {
for (const route of HTTP_ROUTES) {
if (url.includes(route.pattern)) return route;
}
return null;
}
// -----------------------------------------------------------------------
// __TAURI_INTERNALS__
// -----------------------------------------------------------------------
window.__TAURI_INTERNALS__ = {
metadata: {
currentWindow: { label: "main" },
currentWebview: { label: "main" },
},
transformCallback: (callback, once) => {
const id = __callbackId++;
if (typeof callback === "function") {
window["__tcb_" + id] = callback;
}
return id;
},
invoke: async (cmd, args) => {
// ---- Events ----
if (cmd === "plugin:event|listen") {
const eventName = args?.event;
const handlerId = args?.handler;
const cb = window["__tcb_" + handlerId];
if (eventName && cb) {
if (!__eventListeners[eventName]) __eventListeners[eventName] = [];
__eventListeners[eventName].push({ id: handlerId, handler: cb });
}
return handlerId || 0;
}
if (cmd === "plugin:event|unlisten") return;
// ---- HTTP: fetch (step 1 — register request, return rid) ----
if (cmd === "plugin:http|fetch") {
const url = args?.clientConfig?.url || args?.url || "";
const rid = __nextRid++;
const route = matchRoute(url);
__pendingFetch[rid] = { url, route };
return rid;
}
// ---- HTTP: fetch_send (step 2 — return status + headers) ----
if (cmd === "plugin:http|fetch_send") {
const rid = args?.rid;
const pending = __pendingFetch[rid];
delete __pendingFetch[rid];
const responseRid = __nextRid++;
if (pending?.route) {
const bodyStr = JSON.stringify(pending.route.body);
const encoder = new TextEncoder();
const bodyBytes = encoder.encode(bodyStr);
__pendingBody[responseRid] = bodyBytes;
__bodyRead[responseRid] = false;
return {
status: pending.route.status,
statusText: pending.route.status === 200 ? "OK" : "Error",
url: pending.url,
headers: [["content-type", "application/json"]],
rid: responseRid,
};
}
// No matching route — 404
const fallback = JSON.stringify({ error: "NOT_FOUND", message: "mocked 404" });
const encoder = new TextEncoder();
__pendingBody[responseRid] = encoder.encode(fallback);
__bodyRead[responseRid] = false;
return {
status: 404,
statusText: "Not Found",
url: pending?.url || "",
headers: [["content-type", "application/json"]],
rid: responseRid,
};
}
// ---- HTTP: fetch_read_body (step 3 — return body bytes) ----
if (cmd === "plugin:http|fetch_read_body") {
const rid = args?.rid;
const body = __pendingBody[rid];
if (body && !__bodyRead[rid]) {
__bodyRead[rid] = true;
const result = Array.from(body);
result.push(0); // 0 = not end yet
return result;
}
// End signal: [1]
delete __pendingBody[rid];
delete __bodyRead[rid];
return [1];
}
// ---- HTTP: cancel ----
if (cmd === "plugin:http|fetch_cancel" || cmd === "plugin:http|fetch_cancel_body") {
return;
}
// ---- WS commands ----
if (cmd === "ws_connect") {
${opts.simulateWsFlow ? `
setTimeout(() => __tauriEmitEvent("ws-state", "open"), 100);
` : ""}
return;
}
if (cmd === "ws_send") {
${opts.simulateWsFlow ? `
try {
const parsed = JSON.parse(args?.message || "{}");
if (parsed.type === "auth") {
setTimeout(() => {
__tauriEmitEvent("ws-message", JSON.stringify(${JSON.stringify(MOCK_AUTH_OK)}));
}, 100);
setTimeout(() => {
__tauriEmitEvent("ws-message", JSON.stringify(${JSON.stringify(readyPayload)}));
}, 200);
}
} catch (e) {}
` : ""}
return;
}
if (cmd === "ws_disconnect") return;
// ---- Credentials ----
if (cmd === "save_credential" || cmd === "delete_credential" || cmd === "load_credential") return null;
// ---- Settings ----
if (cmd === "get_settings") return {};
if (cmd === "save_settings") return;
// ---- Certs ----
if (cmd === "store_cert_fingerprint" || cmd === "get_cert_fingerprint") return null;
// ---- Window/webview plugin stubs ----
if (cmd.startsWith("plugin:window|") || cmd.startsWith("plugin:webview|")) return null;
console.log("[tauri-mock] unhandled invoke:", cmd);
return null;
},
convertFileSrc: (path) => path,
};
`;
}
// ---------------------------------------------------------------------------
// Public API — mock injection
// ---------------------------------------------------------------------------
export async function mockTauriConnect(page: Page): Promise<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
],
simulateWsFlow: false,
}));
}
export async function mockTauriConnectWith2FA(page: Page): Promise<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_2FA_RESPONSE },
],
simulateWsFlow: false,
}));
}
export async function mockTauriFullSession(page: Page): Promise<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
],
simulateWsFlow: true,
}));
}
export async function mockTauriFullSessionWithMessages(page: Page): Promise<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES_RICH },
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
{ pattern: "/api/v1/invites", status: 200, body: MOCK_INVITES },
],
simulateWsFlow: true,
readyOverrides: {
channels: MOCK_CHANNELS_WITH_CATEGORIES,
members: MOCK_MEMBERS_MULTI_ROLE,
},
}));
}
export async function mockTauriFullSessionWithVoice(page: Page): Promise<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
],
simulateWsFlow: true,
readyOverrides: {
channels: MOCK_CHANNELS_WITH_CATEGORIES,
members: MOCK_MEMBERS_MULTI_ROLE,
voice_states: MOCK_VOICE_STATE,
},
}));
}
export async function mockTauriLoginError(page: Page): Promise<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 401, body: { error: "INVALID_CREDENTIALS", message: "Invalid username or password" } },
],
simulateWsFlow: false,
}));
}
// ---------------------------------------------------------------------------
// Public API — page actions
// ---------------------------------------------------------------------------
export async function submitLogin(page: Page): Promise<void> {
await page.locator("#host").fill("localhost:8443");
await page.locator("#username").fill("testuser");
await page.locator("#password").fill("password123");
await page.locator("button.btn-primary[type='submit']").click();
}
/**
* Login and wait for the main app layout to appear.
*/
export async function navigateToMainPage(page: Page): Promise<void> {
await submitLogin(page);
const appLayout = page.locator(".app");
await expect(appLayout).toBeVisible({ timeout: 15_000 });
}
/**
* Emit a WebSocket event from the mock server to the client.
* Must be called after the page has loaded and WS listeners are registered.
*/
export async function emitWsEvent(
page: Page,
eventName: string,
payload: unknown,
): Promise<void> {
await page.evaluate(
({ event, data }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).__tauriEmitEvent(event, typeof data === "string" ? data : JSON.stringify(data));
},
{ event: eventName, data: payload },
);
}
/**
* Emit a WS message event (shorthand for ws-message).
*/
export async function emitWsMessage(page: Page, message: unknown): Promise<void> {
await emitWsEvent(page, "ws-message", JSON.stringify(message));
}
@@ -0,0 +1,54 @@
import { test, expect } from "@playwright/test";
import { mockTauriFullSession, navigateToMainPage } from "./helpers";
// ---------------------------------------------------------------------------
// Tests: Main Page Layout
// ---------------------------------------------------------------------------
test.describe("Main Page Layout", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("app layout has all major sections", async ({ page }) => {
// Server strip
await expect(page.locator(".server-strip")).toBeVisible();
// Channel sidebar
await expect(page.locator(".channel-sidebar")).toBeVisible();
// Chat area
await expect(page.locator(".chat-area")).toBeVisible();
// Chat header
await expect(page.locator(".chat-header")).toBeVisible();
// Messages container
await expect(page.locator(".messages-container")).toBeVisible();
// User bar
await expect(page.locator(".user-bar")).toBeVisible();
});
test("input slot is attached to DOM", async ({ page }) => {
const inputSlot = page.locator(".input-slot");
await expect(inputSlot).toBeAttached();
});
test("typing slot is attached to DOM", async ({ page }) => {
const typingSlot = page.locator(".typing-slot");
await expect(typingSlot).toBeAttached();
});
test("messages slot is visible", async ({ page }) => {
const messagesSlot = page.locator(".messages-slot");
await expect(messagesSlot).toBeVisible();
});
test("member list is visible", async ({ page }) => {
const memberList = page.locator(".member-list");
await expect(memberList).toBeVisible();
});
});
@@ -0,0 +1,73 @@
import { test, expect } from "@playwright/test";
import { mockTauriFullSession, mockTauriFullSessionWithMessages, navigateToMainPage } from "./helpers";
// ---------------------------------------------------------------------------
// Tests: Member List
// ---------------------------------------------------------------------------
test.describe("Member List", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("member list is visible", async ({ page }) => {
const memberList = page.locator(".member-list");
await expect(memberList).toBeVisible();
});
test("member list shows role groups", async ({ page }) => {
const roleGroups = page.locator(".member-role-group");
const count = await roleGroups.count();
expect(count).toBeGreaterThanOrEqual(1);
});
test("member items display usernames", async ({ page }) => {
const memberItem = page.locator(".member-item").first();
await expect(memberItem).toBeVisible();
const name = memberItem.locator(".mi-name");
await expect(name).toBeVisible();
});
test("member items show avatars", async ({ page }) => {
const memberItem = page.locator(".member-item").first();
const avatar = memberItem.locator(".mi-avatar");
await expect(avatar).toBeVisible();
});
test("member items show status indicators", async ({ page }) => {
const memberItem = page.locator(".member-item").first();
const status = memberItem.locator(".mi-status");
await expect(status).toBeAttached();
});
});
test.describe("Member List — Multi-role", () => {
test("shows members from multiple roles", async ({ page }) => {
await mockTauriFullSessionWithMessages(page);
await page.goto("/");
await navigateToMainPage(page);
const memberList = page.locator(".member-list");
await expect(memberList).toBeVisible();
const members = page.locator(".member-item");
const count = await members.count();
expect(count).toBeGreaterThanOrEqual(3);
});
test("offline members have offline class", async ({ page }) => {
await mockTauriFullSessionWithMessages(page);
await page.goto("/");
await navigateToMainPage(page);
// Wait for member list to populate
await page.waitForTimeout(500);
const offlineMembers = page.locator(".member-item.offline");
const count = await offlineMembers.count();
expect(count).toBeGreaterThanOrEqual(1);
});
});
@@ -0,0 +1,61 @@
import { test, expect } from "@playwright/test";
import { mockTauriFullSession, navigateToMainPage } from "./helpers";
// ---------------------------------------------------------------------------
// Tests: Message Input
// ---------------------------------------------------------------------------
test.describe("Message Input", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("message input area is visible", async ({ page }) => {
const inputWrap = page.locator(".message-input-wrap");
await expect(inputWrap).toBeAttached();
});
test("textarea is present and focusable", async ({ page }) => {
const textarea = page.locator(".msg-textarea");
await expect(textarea).toBeAttached();
await textarea.focus();
await expect(textarea).toBeFocused();
});
test("textarea has placeholder with channel name", async ({ page }) => {
const textarea = page.locator(".msg-textarea");
const placeholder = await textarea.getAttribute("placeholder");
expect(placeholder).toMatch(/Message #/);
});
test("send button exists", async ({ page }) => {
const sendBtn = page.locator(".send-btn");
await expect(sendBtn).toBeAttached();
});
test("emoji button exists", async ({ page }) => {
const emojiBtn = page.locator(".emoji-btn");
await expect(emojiBtn).toBeAttached();
});
test("attach button exists", async ({ page }) => {
const attachBtn = page.locator(".attach-btn");
await expect(attachBtn).toBeAttached();
});
test("can type in the textarea", async ({ page }) => {
const textarea = page.locator(".msg-textarea");
await textarea.fill("Hello, this is a test message");
await expect(textarea).toHaveValue("Hello, this is a test message");
});
test("reply bar is hidden by default", async ({ page }) => {
const replyBar = page.locator(".reply-bar").first();
// Reply bar should exist but not have visible class
await expect(replyBar).toBeAttached();
await expect(replyBar).not.toHaveClass(/visible/);
});
});
@@ -0,0 +1,155 @@
import { test, expect } from "@playwright/test";
import {
mockTauriFullSession,
mockTauriFullSessionWithMessages,
navigateToMainPage,
emitWsMessage,
} from "./helpers";
// ---------------------------------------------------------------------------
// Tests: Message List — basic
// ---------------------------------------------------------------------------
test.describe("Message List", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("messages container is visible", async ({ page }) => {
const container = page.locator(".messages-container");
await expect(container).toBeVisible();
});
test("displays messages after channel load", async ({ page }) => {
const messages = page.locator(".message");
await expect(messages.first()).toBeVisible({ timeout: 10_000 });
});
test("message shows author name", async ({ page }) => {
const author = page.locator(".msg-author").first();
await expect(author).toBeVisible({ timeout: 10_000 });
});
test("message shows content text", async ({ page }) => {
const text = page.locator(".msg-text").first();
await expect(text).toBeVisible({ timeout: 10_000 });
await expect(text).toHaveText("Hello world!");
});
test("message shows timestamp", async ({ page }) => {
const time = page.locator(".msg-time").first();
await expect(time).toBeVisible({ timeout: 10_000 });
});
test("message shows avatar", async ({ page }) => {
const avatar = page.locator(".msg-avatar").first();
await expect(avatar).toBeVisible({ timeout: 10_000 });
});
});
// ---------------------------------------------------------------------------
// Tests: Message List — rich content
// ---------------------------------------------------------------------------
test.describe("Message List — Rich Content", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSessionWithMessages(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("displays multiple messages", async ({ page }) => {
const messages = page.locator(".message");
await expect(messages.first()).toBeVisible({ timeout: 10_000 });
const count = await messages.count();
expect(count).toBeGreaterThanOrEqual(3);
});
test("shows edited indicator", async ({ page }) => {
const edited = page.locator(".msg-edited");
await expect(edited.first()).toBeVisible({ timeout: 10_000 });
});
test("shows reply references", async ({ page }) => {
const replyRef = page.locator(".msg-reply-ref");
await expect(replyRef.first()).toBeVisible({ timeout: 10_000 });
const replyAuthor = replyRef.first().locator(".rr-author");
await expect(replyAuthor).toBeVisible();
});
test("renders code blocks", async ({ page }) => {
const codeBlock = page.locator(".msg-codeblock");
await expect(codeBlock.first()).toBeVisible({ timeout: 10_000 });
});
test("shows reactions on messages", async ({ page }) => {
const reactions = page.locator(".msg-reactions");
await expect(reactions.first()).toBeVisible({ timeout: 10_000 });
const chip = page.locator(".reaction-chip").first();
await expect(chip).toBeVisible();
});
test("shows image attachments", async ({ page }) => {
const image = page.locator(".msg-image");
await expect(image.first()).toBeAttached({ timeout: 10_000 });
});
test("shows file attachments", async ({ page }) => {
const file = page.locator(".msg-file");
await expect(file.first()).toBeAttached({ timeout: 10_000 });
const filename = file.first().locator(".msg-file-name");
await expect(filename).toBeVisible();
});
test("grouped messages have grouped class", async ({ page }) => {
await page.waitForTimeout(500);
const grouped = page.locator(".message.grouped");
const count = await grouped.count();
// Messages from same author in quick succession should be grouped
expect(count).toBeGreaterThanOrEqual(1);
});
test("day dividers are shown", async ({ page }) => {
const divider = page.locator(".msg-day-divider");
await expect(divider.first()).toBeAttached({ timeout: 10_000 });
});
});
// ---------------------------------------------------------------------------
// Tests: Message List — real-time
// ---------------------------------------------------------------------------
test.describe("Message List — Real-time", () => {
test("new message appears via WebSocket", async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
// Wait for initial messages to load
await expect(page.locator(".message").first()).toBeVisible({ timeout: 10_000 });
// Emit a new message via WebSocket
await emitWsMessage(page, {
type: "chat_message",
payload: {
id: 200,
channel_id: 1,
user: { id: 2, username: "otheruser", avatar: "" },
content: "A new real-time message!",
timestamp: "2026-03-15T10:05:00Z",
attachments: [],
reply_to: null,
},
});
// The new message should appear
const newMsg = page.locator(".msg-text", { hasText: "A new real-time message!" });
await expect(newMsg).toBeVisible({ timeout: 5_000 });
});
});
@@ -0,0 +1,231 @@
import { test, expect } from "@playwright/test";
import { mockTauriFullSession, mockTauriFullSessionWithMessages, navigateToMainPage } from "./helpers";
// ---------------------------------------------------------------------------
// Tests: Quick Switcher (Ctrl+K)
// ---------------------------------------------------------------------------
test.describe("Quick Switcher", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("Ctrl+K opens quick switcher", async ({ page }) => {
await page.keyboard.press("Control+k");
const overlay = page.locator(".quick-switcher-overlay");
await expect(overlay).toBeVisible({ timeout: 3_000 });
});
test("quick switcher has search input", async ({ page }) => {
await page.keyboard.press("Control+k");
const input = page.locator(".quick-switcher__input");
await expect(input).toBeVisible({ timeout: 3_000 });
await expect(input).toBeFocused();
});
test("quick switcher shows channel results", async ({ page }) => {
await page.keyboard.press("Control+k");
const results = page.locator(".quick-switcher__item");
await expect(results.first()).toBeVisible({ timeout: 3_000 });
});
test("first result is highlighted by default", async ({ page }) => {
await page.keyboard.press("Control+k");
const active = page.locator(".quick-switcher__item--active");
await expect(active).toBeVisible({ timeout: 3_000 });
});
test("Escape closes quick switcher", async ({ page }) => {
await page.keyboard.press("Control+k");
await expect(page.locator(".quick-switcher-overlay")).toBeVisible({ timeout: 3_000 });
await page.keyboard.press("Escape");
await expect(page.locator(".quick-switcher-overlay")).not.toBeVisible();
});
test("clicking overlay backdrop closes quick switcher", async ({ page }) => {
await page.keyboard.press("Control+k");
const overlay = page.locator(".quick-switcher-overlay");
await expect(overlay).toBeVisible({ timeout: 3_000 });
// Click the backdrop (not the modal)
await overlay.click({ position: { x: 10, y: 10 } });
await expect(overlay).not.toBeVisible();
});
test("typing in search filters results", async ({ page }) => {
await page.keyboard.press("Control+k");
const input = page.locator(".quick-switcher__input");
await expect(input).toBeVisible({ timeout: 3_000 });
const initialCount = await page.locator(".quick-switcher__item").count();
await input.fill("general");
await page.waitForTimeout(200);
const filteredCount = await page.locator(".quick-switcher__item").count();
expect(filteredCount).toBeLessThanOrEqual(initialCount);
expect(filteredCount).toBeGreaterThanOrEqual(1);
});
test("Enter selects highlighted result", async ({ page }) => {
await page.keyboard.press("Control+k");
await expect(page.locator(".quick-switcher__item").first()).toBeVisible({ timeout: 3_000 });
await page.keyboard.press("Enter");
await expect(page.locator(".quick-switcher-overlay")).not.toBeVisible();
});
test("arrow keys navigate results", async ({ page }) => {
await page.keyboard.press("Control+k");
await expect(page.locator(".quick-switcher__item").first()).toBeVisible({ timeout: 3_000 });
const firstItem = page.locator(".quick-switcher__item").first();
const firstIsActive = await firstItem.evaluate((el) =>
el.classList.contains("quick-switcher__item--active"),
);
await page.keyboard.press("ArrowDown");
await page.waitForTimeout(100);
// Active state should have moved
const secondItem = page.locator(".quick-switcher__item").nth(1);
if (await secondItem.count() > 0) {
const secondIsActive = await secondItem.evaluate((el) =>
el.classList.contains("quick-switcher__item--active"),
);
expect(firstIsActive || secondIsActive).toBe(true);
}
});
});
// ---------------------------------------------------------------------------
// Tests: Emoji Picker
// ---------------------------------------------------------------------------
test.describe("Emoji Picker", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("emoji button opens emoji picker", async ({ page }) => {
const emojiBtn = page.locator(".emoji-btn");
await emojiBtn.click();
const picker = page.locator(".emoji-picker.open");
await expect(picker).toBeVisible({ timeout: 3_000 });
});
test("emoji picker has search input", async ({ page }) => {
await page.locator(".emoji-btn").click();
const search = page.locator(".ep-search");
await expect(search).toBeVisible({ timeout: 3_000 });
});
test("emoji picker shows emoji grid", async ({ page }) => {
await page.locator(".emoji-btn").click();
const grid = page.locator(".ep-grid");
await expect(grid.first()).toBeVisible({ timeout: 3_000 });
});
test("emoji picker shows category labels", async ({ page }) => {
await page.locator(".emoji-btn").click();
const categoryLabel = page.locator(".ep-category-label");
await expect(categoryLabel.first()).toBeVisible({ timeout: 3_000 });
});
test("emoji picker has clickable emojis", async ({ page }) => {
await page.locator(".emoji-btn").click();
const emoji = page.locator(".ep-emoji");
await expect(emoji.first()).toBeVisible({ timeout: 3_000 });
});
test("searching filters emojis", async ({ page }) => {
await page.locator(".emoji-btn").click();
const search = page.locator(".ep-search");
await expect(search).toBeVisible({ timeout: 3_000 });
// Get count before filtering
const allEmojis = page.locator(".ep-emoji");
const countBefore = await allEmojis.count();
expect(countBefore).toBeGreaterThan(10);
// Search for a specific emoji character that exists in the grid
await search.fill("\uD83D\uDE00");
await page.waitForTimeout(200);
const countAfter = await allEmojis.count();
// After filtering, should have fewer results
expect(countAfter).toBeLessThan(countBefore);
expect(countAfter).toBeGreaterThanOrEqual(1);
});
});
// ---------------------------------------------------------------------------
// Tests: Pinned Messages
// ---------------------------------------------------------------------------
test.describe("Pinned Messages", () => {
test("pinned panel can be opened from chat header", async ({ page }) => {
await mockTauriFullSessionWithMessages(page);
await page.goto("/");
await navigateToMainPage(page);
// Look for a pin button in chat header tools area
const pinBtn = page.locator(".ch-tools button", { hasText: /pin/i });
if (await pinBtn.count() > 0) {
await pinBtn.click();
const panel = page.locator(".pinned-panel");
await expect(panel).toBeVisible({ timeout: 3_000 });
}
});
test("pinned panel has close button", async ({ page }) => {
await mockTauriFullSessionWithMessages(page);
await page.goto("/");
await navigateToMainPage(page);
const pinBtn = page.locator(".ch-tools button", { hasText: /pin/i });
if (await pinBtn.count() > 0) {
await pinBtn.click();
const closeBtn = page.locator(".pinned-panel__close");
await expect(closeBtn).toBeVisible({ timeout: 3_000 });
}
});
});
// ---------------------------------------------------------------------------
// Tests: Invite Manager
// ---------------------------------------------------------------------------
test.describe("Invite Manager", () => {
test("invite manager can be opened", async ({ page }) => {
await mockTauriFullSessionWithMessages(page);
await page.goto("/");
await navigateToMainPage(page);
// Invite manager is typically opened from channel sidebar header or server context
const inviteBtn = page.locator("button", { hasText: /invite/i });
if (await inviteBtn.count() > 0) {
await inviteBtn.first().click();
const overlay = page.locator(".invite-manager-overlay");
await expect(overlay).toBeVisible({ timeout: 3_000 });
}
});
});
@@ -0,0 +1,37 @@
import { test, expect } from "@playwright/test";
import { mockTauriFullSession, navigateToMainPage } from "./helpers";
// ---------------------------------------------------------------------------
// Tests: Server Strip
// ---------------------------------------------------------------------------
test.describe("Server Strip", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("server strip is visible with server icons", async ({ page }) => {
const strip = page.locator(".server-strip");
await expect(strip).toBeVisible();
const icons = page.locator(".server-strip .server-icon");
await expect(icons.first()).toBeVisible();
});
test("active server icon has active class", async ({ page }) => {
const activeIcon = page.locator(".server-strip .server-icon.active");
await expect(activeIcon).toBeVisible();
});
test("server separator exists between icons", async ({ page }) => {
const separator = page.locator(".server-strip .server-separator");
await expect(separator).toBeAttached();
});
test("add server button exists", async ({ page }) => {
const addBtn = page.locator(".server-strip .server-icon.add");
await expect(addBtn).toBeVisible();
});
});
@@ -0,0 +1,302 @@
import { test, expect } from "@playwright/test";
import { mockTauriFullSession, navigateToMainPage } from "./helpers";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function openSettings(page: import("@playwright/test").Page): Promise<void> {
// Settings is opened via user bar settings button (gear icon)
const settingsBtn = page.locator(".ub-controls button").last();
await settingsBtn.click();
const overlay = page.locator(".settings-overlay.open");
await expect(overlay).toBeVisible({ timeout: 5_000 });
}
// ---------------------------------------------------------------------------
// Tests: Settings Overlay — structure
// ---------------------------------------------------------------------------
test.describe("Settings Overlay", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("settings overlay opens from user bar", async ({ page }) => {
await openSettings(page);
const overlay = page.locator(".settings-overlay");
await expect(overlay).toHaveClass(/open/);
});
test("settings overlay has sidebar with tabs", async ({ page }) => {
await openSettings(page);
const sidebar = page.locator(".settings-sidebar");
await expect(sidebar).toBeVisible();
const tabs = sidebar.locator("button.settings-nav-item");
const count = await tabs.count();
expect(count).toBeGreaterThanOrEqual(5);
});
test("settings overlay starts on Account tab", async ({ page }) => {
await openSettings(page);
const activeTab = page.locator(".settings-sidebar button.settings-nav-item.active");
await expect(activeTab).toHaveText("Account");
});
test("close button closes settings", async ({ page }) => {
await openSettings(page);
const closeBtn = page.locator(".settings-close-btn");
await closeBtn.click();
const overlay = page.locator(".settings-overlay");
await expect(overlay).not.toHaveClass(/open/);
});
test("Escape key closes settings", async ({ page }) => {
await openSettings(page);
await page.keyboard.press("Escape");
const overlay = page.locator(".settings-overlay");
await expect(overlay).not.toHaveClass(/open/);
});
test("has Log Out button with danger class", async ({ page }) => {
await openSettings(page);
const logoutBtn = page.locator(".settings-nav-item.danger");
await expect(logoutBtn).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Tests: Settings — Account tab
// ---------------------------------------------------------------------------
test.describe("Settings — Account Tab", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
await openSettings(page);
});
test("shows username in account card", async ({ page }) => {
const name = page.locator(".ac-name");
await expect(name).toHaveText("testuser");
});
test("shows account avatar", async ({ page }) => {
const avatar = page.locator(".ac-avatar");
await expect(avatar).toBeVisible();
});
test("has password change fields", async ({ page }) => {
const passwordInputs = page.locator(".settings-content input[type='password']");
const count = await passwordInputs.count();
expect(count).toBeGreaterThanOrEqual(2);
});
test("has Change Password button", async ({ page }) => {
const changePwBtn = page.locator(".ac-btn", { hasText: "Change Password" });
await expect(changePwBtn).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Tests: Settings — Appearance tab
// ---------------------------------------------------------------------------
test.describe("Settings — Appearance Tab", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
await openSettings(page);
// Switch to Appearance tab
const tabs = page.locator(".settings-sidebar button.settings-nav-item");
await tabs.nth(1).click();
});
test("shows theme options", async ({ page }) => {
const themeOptions = page.locator(".theme-opt");
const count = await themeOptions.count();
expect(count).toBeGreaterThanOrEqual(2);
});
test("clicking theme option activates it", async ({ page }) => {
const themeOptions = page.locator(".theme-opt");
const second = themeOptions.nth(1);
await second.click();
await expect(second).toHaveClass(/active/);
});
test("shows font size slider", async ({ page }) => {
const slider = page.locator(".settings-slider").first();
await expect(slider).toBeVisible();
});
test("shows compact mode toggle", async ({ page }) => {
const toggle = page.locator(".toggle").first();
await expect(toggle).toBeVisible();
});
test("toggling compact mode changes toggle state", async ({ page }) => {
const toggle = page.locator(".toggle").first();
const initialOn = await toggle.evaluate((el) => el.classList.contains("on"));
await toggle.click();
const afterOn = await toggle.evaluate((el) => el.classList.contains("on"));
expect(afterOn).not.toBe(initialOn);
});
});
// ---------------------------------------------------------------------------
// Tests: Settings — Notifications tab
// ---------------------------------------------------------------------------
test.describe("Settings — Notifications Tab", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
await openSettings(page);
const tabs = page.locator(".settings-sidebar button.settings-nav-item");
await tabs.nth(2).click();
});
test("shows notification toggles", async ({ page }) => {
const toggles = page.locator(".toggle");
const count = await toggles.count();
expect(count).toBeGreaterThanOrEqual(3);
});
test("notification toggles are clickable", async ({ page }) => {
const toggle = page.locator(".toggle").first();
const initialOn = await toggle.evaluate((el) => el.classList.contains("on"));
await toggle.click();
const afterOn = await toggle.evaluate((el) => el.classList.contains("on"));
expect(afterOn).not.toBe(initialOn);
});
});
// ---------------------------------------------------------------------------
// Tests: Settings — Voice & Audio tab
// ---------------------------------------------------------------------------
test.describe("Settings — Voice & Audio Tab", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
await openSettings(page);
const tabs = page.locator(".settings-sidebar button.settings-nav-item");
await tabs.nth(3).click();
});
test("shows device selectors", async ({ page }) => {
const selects = page.locator("select.form-input");
const count = await selects.count();
expect(count).toBeGreaterThanOrEqual(1);
});
test("shows voice sensitivity slider", async ({ page }) => {
const slider = page.locator(".settings-slider");
await expect(slider.first()).toBeVisible();
});
test("shows audio processing toggles", async ({ page }) => {
const toggles = page.locator(".toggle");
const count = await toggles.count();
expect(count).toBeGreaterThanOrEqual(2);
});
});
// ---------------------------------------------------------------------------
// Tests: Settings — Keybinds tab
// ---------------------------------------------------------------------------
test.describe("Settings — Keybinds Tab", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
await openSettings(page);
const tabs = page.locator(".settings-sidebar button.settings-nav-item");
await tabs.nth(4).click();
});
test("shows keybind rows", async ({ page }) => {
const keybindRows = page.locator(".keybind-row");
const count = await keybindRows.count();
expect(count).toBeGreaterThanOrEqual(1);
});
test("keybind rows show keyboard shortcuts", async ({ page }) => {
const kbd = page.locator(".kbd").first();
await expect(kbd).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Tests: Settings — Logs tab
// ---------------------------------------------------------------------------
test.describe("Settings — Logs Tab", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
await openSettings(page);
const tabs = page.locator(".settings-sidebar button.settings-nav-item");
await tabs.nth(5).click();
});
test("shows log viewer", async ({ page }) => {
const logViewer = page.locator(".log-viewer");
await expect(logViewer).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Tests: Settings — tab switching
// ---------------------------------------------------------------------------
test.describe("Settings — Tab Switching", () => {
test("switching tabs updates active class and content", async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
await openSettings(page);
const tabs = page.locator(".settings-sidebar button.settings-nav-item");
// Click each tab and verify it becomes active
const tabCount = await tabs.count();
for (let i = 0; i < Math.min(tabCount, 6); i++) {
const tab = tabs.nth(i);
const tabName = await tab.textContent();
// Skip Log Out button
if (tabName === "Log Out") continue;
await tab.click();
await expect(tab).toHaveClass(/active/);
}
});
});
@@ -0,0 +1,56 @@
import { test, expect } from "@playwright/test";
import { mockTauriFullSession, navigateToMainPage, emitWsMessage } from "./helpers";
// ---------------------------------------------------------------------------
// Tests: Typing Indicator
// ---------------------------------------------------------------------------
test.describe("Typing Indicator", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("typing indicator slot exists", async ({ page }) => {
const slot = page.locator(".typing-slot");
await expect(slot).toBeAttached();
});
test("typing bar is empty by default", async ({ page }) => {
const typingBar = page.locator(".typing-bar");
if (await typingBar.count() > 0) {
// When empty, typing bar should have no visible dots text
const text = await typingBar.textContent();
expect(text?.trim()).toBe("");
}
});
test("typing indicator appears when someone types", async ({ page }) => {
// Emit a typing event
await emitWsMessage(page, {
type: "typing",
payload: {
channel_id: 1,
user_id: 2,
},
});
const typingBar = page.locator(".typing-bar");
// Should show typing text after event
await expect(typingBar).not.toBeEmpty({ timeout: 3_000 });
});
test("typing dots animate", async ({ page }) => {
await emitWsMessage(page, {
type: "typing",
payload: {
channel_id: 1,
user_id: 2,
},
});
const dots = page.locator(".typing-dots");
await expect(dots).toBeAttached({ timeout: 3_000 });
});
});
@@ -0,0 +1,49 @@
import { test, expect } from "@playwright/test";
import { mockTauriFullSession, navigateToMainPage } from "./helpers";
// ---------------------------------------------------------------------------
// Tests: User Bar
// ---------------------------------------------------------------------------
test.describe("User Bar", () => {
test.beforeEach(async ({ page }) => {
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
});
test("user bar is visible", async ({ page }) => {
const userBar = page.locator(".user-bar");
await expect(userBar).toBeVisible();
});
test("user bar shows username", async ({ page }) => {
const name = page.locator(".ub-name");
await expect(name).toBeVisible();
await expect(name).toHaveText("testuser");
});
test("user bar shows avatar", async ({ page }) => {
const avatar = page.locator(".ub-avatar");
await expect(avatar).toBeVisible();
});
test("user bar shows status", async ({ page }) => {
const status = page.locator(".ub-status");
await expect(status).toBeVisible();
});
test("user bar has control buttons", async ({ page }) => {
const controls = page.locator(".ub-controls");
await expect(controls).toBeVisible();
const buttons = controls.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(2);
});
test("user bar has status dot", async ({ page }) => {
const statusDot = page.locator(".user-bar .status-dot");
await expect(statusDot).toBeAttached();
});
});
@@ -0,0 +1,170 @@
import { test, expect } from "@playwright/test";
import { mockTauriFullSessionWithVoice, navigateToMainPage, emitWsMessage } from "./helpers";
// ---------------------------------------------------------------------------
// Tests: Voice Widget
// ---------------------------------------------------------------------------
test.describe("Voice Widget", () => {
test("voice widget is hidden by default when no voice state", async ({ page }) => {
// Use full session WITHOUT voice to check default hidden state
const { mockTauriFullSession } = await import("./helpers");
await mockTauriFullSession(page);
await page.goto("/");
await navigateToMainPage(page);
const widget = page.locator(".voice-widget");
if (await widget.count() > 0) {
await expect(widget).not.toHaveClass(/visible/);
}
});
test("voice widget appears when in voice channel", async ({ page }) => {
await mockTauriFullSessionWithVoice(page);
await page.goto("/");
await navigateToMainPage(page);
// Emit voice state to trigger widget visibility
await emitWsMessage(page, {
type: "voice_state",
payload: {
user_id: 1,
username: "testuser",
channel_id: 10,
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
},
});
const widget = page.locator(".voice-widget.visible");
await expect(widget).toBeVisible({ timeout: 5_000 });
});
test("voice widget shows channel name", async ({ page }) => {
await mockTauriFullSessionWithVoice(page);
await page.goto("/");
await navigateToMainPage(page);
await emitWsMessage(page, {
type: "voice_state",
payload: {
user_id: 1,
username: "testuser",
channel_id: 10,
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
},
});
const channelName = page.locator(".vw-channel");
await expect(channelName).toBeVisible({ timeout: 5_000 });
});
test("voice widget shows control buttons", async ({ page }) => {
await mockTauriFullSessionWithVoice(page);
await page.goto("/");
await navigateToMainPage(page);
await emitWsMessage(page, {
type: "voice_state",
payload: {
user_id: 1,
username: "testuser",
channel_id: 10,
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
},
});
const controls = page.locator(".vw-controls");
await expect(controls).toBeVisible({ timeout: 5_000 });
const buttons = controls.locator("button");
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(2);
});
test("voice widget shows connected users list", async ({ page }) => {
await mockTauriFullSessionWithVoice(page);
await page.goto("/");
await navigateToMainPage(page);
await emitWsMessage(page, {
type: "voice_state",
payload: {
user_id: 1,
username: "testuser",
channel_id: 10,
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
},
});
const usersList = page.locator(".voice-users-list");
await expect(usersList).toBeVisible({ timeout: 5_000 });
});
test("voice widget has disconnect button", async ({ page }) => {
await mockTauriFullSessionWithVoice(page);
await page.goto("/");
await navigateToMainPage(page);
await emitWsMessage(page, {
type: "voice_state",
payload: {
user_id: 1,
username: "testuser",
channel_id: 10,
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
},
});
const widget = page.locator(".voice-widget.visible");
await expect(widget).toBeVisible({ timeout: 5_000 });
// Disconnect button should be in controls
const disconnectBtn = page.locator(".vw-controls .disconnect");
if (await disconnectBtn.count() > 0) {
await expect(disconnectBtn).toBeVisible();
}
});
test("voice widget shows Voice Connected header", async ({ page }) => {
await mockTauriFullSessionWithVoice(page);
await page.goto("/");
await navigateToMainPage(page);
await emitWsMessage(page, {
type: "voice_state",
payload: {
user_id: 1,
username: "testuser",
channel_id: 10,
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
},
});
const header = page.locator(".vw-connected");
await expect(header).toBeVisible({ timeout: 5_000 });
});
});
@@ -168,7 +168,8 @@ describe("Store integration via dispatcher", () => {
const channels = channelsStore.getState().channels;
expect(channels.size).toBe(3);
expect(channels.get(1)?.name).toBe("general");
expect(channels.get(1)?.unreadCount).toBe(3);
// Auto-select first text channel clears its unread count
expect(channels.get(1)?.unreadCount).toBe(0);
expect(channels.get(3)?.type).toBe("voice");
// Members
@@ -209,10 +210,10 @@ describe("Store integration via dispatcher", () => {
});
it("adds message to store and increments unread on non-active channel", () => {
// No active channel set, so channel 1 is non-active
// After ready, channel 1 is auto-selected. Send to channel 2 (non-active).
ws.simulate("chat_message", {
id: 100,
channel_id: 1,
channel_id: 2,
user: { id: 10, username: "sender", avatar: null },
content: "Hello!",
reply_to: null,
@@ -220,11 +221,11 @@ describe("Store integration via dispatcher", () => {
timestamp: "2026-03-15T12:00:00Z",
});
const messages = messagesStore.getState().messagesByChannel.get(1);
const messages = messagesStore.getState().messagesByChannel.get(2);
expect(messages).toHaveLength(1);
expect(messages![0]!.content).toBe("Hello!");
const channel = channelsStore.getState().channels.get(1);
const channel = channelsStore.getState().channels.get(2);
expect(channel?.unreadCount).toBe(1);
});
@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createConnectedOverlay } from "@components/ConnectedOverlay";
describe("ConnectedOverlay", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
function makeOverlay(onReady = vi.fn()) {
return createConnectedOverlay({
serverName: "TestServer",
username: "testuser",
motd: "Welcome to the test server!",
onReady,
});
}
it("creates overlay element with connected-overlay class", () => {
const overlay = makeOverlay();
expect(overlay.element.classList.contains("connected-overlay")).toBe(true);
overlay.destroy();
});
it("is hidden by default (no visible class)", () => {
const overlay = makeOverlay();
expect(overlay.element.classList.contains("visible")).toBe(false);
overlay.destroy();
});
it("show() adds visible class", () => {
const overlay = makeOverlay();
overlay.show();
expect(overlay.element.classList.contains("visible")).toBe(true);
overlay.destroy();
});
it("renders server icon with first letter", () => {
const overlay = makeOverlay();
const icon = overlay.element.querySelector(".connected-srv-icon");
expect(icon).not.toBeNull();
expect(icon!.textContent).toBe("T");
overlay.destroy();
});
it("renders connected text", () => {
const overlay = makeOverlay();
const text = overlay.element.querySelector(".connected-text");
expect(text).not.toBeNull();
expect(text!.textContent).toBe("Connected!");
overlay.destroy();
});
it("renders username", () => {
const overlay = makeOverlay();
const user = overlay.element.querySelector(".connected-user");
expect(user).not.toBeNull();
expect(user!.textContent).toBe("Logged in as testuser");
overlay.destroy();
});
it("renders MOTD", () => {
const overlay = makeOverlay();
const motd = overlay.element.querySelector(".connected-motd");
expect(motd).not.toBeNull();
expect(motd!.textContent).toBe("Welcome to the test server!");
overlay.destroy();
});
it("renders loading spinner text", () => {
const overlay = makeOverlay();
const loader = overlay.element.querySelector(".connected-loader span");
expect(loader).not.toBeNull();
expect(loader!.textContent).toBe("Loading server data...");
overlay.destroy();
});
it("renders check badge SVG", () => {
const overlay = makeOverlay();
const badge = overlay.element.querySelector(".connected-check-badge");
expect(badge).not.toBeNull();
const svg = badge!.querySelector("svg");
expect(svg).not.toBeNull();
overlay.destroy();
});
it("markReady() changes loader text", () => {
const overlay = makeOverlay();
overlay.markReady();
const loader = overlay.element.querySelector(".connected-loader span");
expect(loader!.textContent).toContain("Ready!");
overlay.destroy();
});
it("markReady() hides spinner", () => {
const overlay = makeOverlay();
overlay.markReady();
const spinner = overlay.element.querySelector(".spinner") as HTMLElement;
expect(spinner.style.display).toBe("none");
overlay.destroy();
});
it("markReady() calls onReady after delay", () => {
const onReady = vi.fn();
const overlay = makeOverlay(onReady);
overlay.markReady();
expect(onReady).not.toHaveBeenCalled();
vi.advanceTimersByTime(800);
expect(onReady).toHaveBeenCalledOnce();
overlay.destroy();
});
it("destroy() prevents onReady callback", () => {
const onReady = vi.fn();
const overlay = makeOverlay(onReady);
overlay.markReady();
overlay.destroy();
vi.advanceTimersByTime(800);
expect(onReady).not.toHaveBeenCalled();
});
it("empty MOTD renders empty motd div", () => {
const overlay = createConnectedOverlay({
serverName: "Server",
username: "user",
motd: "",
onReady: vi.fn(),
});
const motd = overlay.element.querySelector(".connected-motd");
expect(motd).not.toBeNull();
expect(motd!.textContent).toBe("");
overlay.destroy();
});
});
+477 -188
View File
@@ -1,32 +1,18 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
import {
createProfileManager,
type StorageBackend,
type PersistenceBackend,
type CreateProfileData,
type ServerProfile,
type FetchFn,
type HealthStatus,
type ProfilesState,
} from "@lib/profiles";
// ---------------------------------------------------------------------------
// Mock StorageBackend backed by a Map
// ---------------------------------------------------------------------------
function createMockBackend(): StorageBackend {
const store = new Map<string, string>();
return {
get(key: string): string | null {
return store.get(key) ?? null;
},
set(key: string, value: string): void {
store.set(key, value);
},
remove(key: string): void {
store.delete(key);
},
};
}
// ---------------------------------------------------------------------------
// Deterministic UUID stub
// ---------------------------------------------------------------------------
let uuidCounter = 0;
function nextUuid(): string {
@@ -34,9 +20,51 @@ function nextUuid(): string {
return `00000000-0000-0000-0000-${String(uuidCounter).padStart(12, "0")}`;
}
// ---------------------------------------------------------------------------
// Mock persistence backend
// ---------------------------------------------------------------------------
function createMockBackend(): PersistenceBackend & {
saved: Array<{ schemaVersion: number; profiles: readonly ServerProfile[] }>;
} {
let stored: { schemaVersion: number; profiles: readonly ServerProfile[] } | null =
null;
const saved: Array<{ schemaVersion: number; profiles: readonly ServerProfile[] }> =
[];
return {
saved,
async load() {
return stored;
},
async save(data) {
stored = data;
saved.push(data);
},
};
}
// ---------------------------------------------------------------------------
// Mock fetch
// ---------------------------------------------------------------------------
function createMockFetch(
handler: (url: string, init?: RequestInit) => Promise<Response>,
): FetchFn {
return handler as unknown as FetchFn;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const sampleData: CreateProfileData = {
name: "Dev Server",
host: "localhost:8443",
@@ -56,8 +84,10 @@ const sampleData2: CreateProfileData = {
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ProfileManager", () => {
let backend: StorageBackend;
let backend: ReturnType<typeof createMockBackend>;
let mockFetch: Mock;
beforeEach(() => {
backend = createMockBackend();
@@ -65,197 +95,456 @@ describe("ProfileManager", () => {
vi.stubGlobal("crypto", {
randomUUID: vi.fn(() => nextUuid()),
});
mockFetch = vi.fn();
});
// 1. Initial state is empty
it("starts with an empty profile list", () => {
const mgr = createProfileManager(backend);
expect(mgr.getAll()).toEqual([]);
function mgr(fetchFn?: FetchFn) {
return createProfileManager(backend, fetchFn ?? (mockFetch as unknown as FetchFn));
}
// ── CRUD ─────────────────────────────────────────────────
describe("CRUD operations", () => {
it("starts with an empty profile list", () => {
const m = mgr();
expect(m.getAll()).toEqual([]);
});
it("adds a profile with a generated UUID", () => {
const m = mgr();
const profile = m.addProfile(sampleData);
expect(profile.id).toBe("00000000-0000-0000-0000-000000000001");
expect(profile.name).toBe("Dev Server");
expect(profile.host).toBe("localhost:8443");
expect(profile.username).toBe("alice");
expect(profile.color).toBe("#ff5500");
expect(profile.autoConnect).toBe(false);
expect(profile.lastConnected).toBeNull();
expect(m.getAll()).toHaveLength(1);
});
it("retrieves a profile by id", () => {
const m = mgr();
const created = m.addProfile(sampleData);
expect(m.getById(created.id)).toEqual(created);
expect(m.getById("nonexistent")).toBeNull();
});
it("updates a profile immutably", () => {
const m = mgr();
const original = m.addProfile(sampleData);
const updated = m.updateProfile(original.id, { name: "Renamed" });
expect(updated).not.toBeNull();
expect(updated!.name).toBe("Renamed");
expect(updated!.host).toBe(original.host);
// Original object not mutated
expect(original.name).toBe("Dev Server");
// Store has the updated version
expect(m.getById(original.id)!.name).toBe("Renamed");
});
it("returns null when updating a nonexistent profile", () => {
const m = mgr();
expect(m.updateProfile("missing", { name: "X" })).toBeNull();
});
it("removes an existing profile", () => {
const m = mgr();
const profile = m.addProfile(sampleData);
expect(m.removeProfile(profile.id)).toBe(true);
expect(m.getAll()).toHaveLength(0);
expect(m.getById(profile.id)).toBeNull();
});
it("returns false when removing a nonexistent profile", () => {
const m = mgr();
expect(m.removeProfile("missing")).toBe(false);
});
it("sets lastConnected to current ISO timestamp", () => {
const m = mgr();
const profile = m.addProfile(sampleData);
const before = new Date().toISOString();
m.setLastConnected(profile.id);
const after = new Date().toISOString();
const updated = m.getById(profile.id)!;
expect(updated.lastConnected).not.toBeNull();
expect(updated.lastConnected! >= before).toBe(true);
expect(updated.lastConnected! <= after).toBe(true);
// Original not mutated
expect(profile.lastConnected).toBeNull();
});
it("does nothing when setting lastConnected on nonexistent profile", () => {
const m = mgr();
// Should not throw
m.setLastConnected("missing");
});
});
// 2. create adds a profile with UUID
it("creates a profile with a generated UUID and schema v1", () => {
const mgr = createProfileManager(backend);
const profile = mgr.create(sampleData);
// ── Auto-connect ─────────────────────────────────────────
expect(profile.id).toBe("00000000-0000-0000-0000-000000000001");
expect(profile.name).toBe("Dev Server");
expect(profile.host).toBe("localhost:8443");
expect(profile.username).toBe("alice");
expect(profile.color).toBe("#ff5500");
expect(profile.autoConnect).toBe(false);
expect(profile.lastConnected).toBeNull();
expect(profile.schemaVersion).toBe(1);
expect(mgr.getAll()).toHaveLength(1);
describe("auto-connect", () => {
it("returns the first auto-connect profile", () => {
const m = mgr();
m.addProfile(sampleData); // autoConnect: false
const autoProfile = m.addProfile(sampleData2); // autoConnect: true
expect(m.getAutoConnectProfile()).toEqual(autoProfile);
});
it("returns null when no profiles have autoConnect", () => {
const m = mgr();
m.addProfile(sampleData);
expect(m.getAutoConnectProfile()).toBeNull();
});
it("returns null when no profiles exist", () => {
const m = mgr();
expect(m.getAutoConnectProfile()).toBeNull();
});
});
// 3. getById returns the correct profile
it("retrieves a profile by id", () => {
const mgr = createProfileManager(backend);
const created = mgr.create(sampleData);
// ── Health check ─────────────────────────────────────────
expect(mgr.getById(created.id)).toEqual(created);
expect(mgr.getById("nonexistent")).toBeNull();
describe("health checks", () => {
it("returns online status for a healthy server", async () => {
const fetchFn = createMockFetch(async () =>
jsonResponse({ version: "1.2.3" }),
);
const m = mgr(fetchFn);
const profile = m.addProfile(sampleData);
const result = await m.checkHealth(profile.id);
expect(result.status).toBe("online");
expect(result.version).toBe("1.2.3");
expect(typeof result.latencyMs).toBe("number");
});
it("sets status to checking before resolving", async () => {
const states: Array<HealthStatus | undefined> = [];
let resolveReq!: () => void;
const pending = new Promise<void>((r) => {
resolveReq = r;
});
const fetchFn = createMockFetch(async () => {
await pending;
return jsonResponse({ version: "1.0.0" });
});
const m = mgr(fetchFn);
const profile = m.addProfile(sampleData);
// Subscribe to capture the "checking" state
m.store.subscribe((state: ProfilesState) => {
states.push(state.healthStatuses.get(profile.id));
});
const healthPromise = m.checkHealth(profile.id);
// At this point, state should have been set to "checking"
const checkingState = m.store.getState().healthStatuses.get(profile.id);
expect(checkingState?.status).toBe("checking");
resolveReq();
await healthPromise;
const finalState = m.store.getState().healthStatuses.get(profile.id);
expect(finalState?.status).toBe("online");
});
it("returns offline when fetch throws", async () => {
const fetchFn = createMockFetch(async () => {
throw new Error("network error");
});
const m = mgr(fetchFn);
const profile = m.addProfile(sampleData);
const result = await m.checkHealth(profile.id);
expect(result.status).toBe("offline");
expect(result.latencyMs).toBeNull();
expect(result.version).toBeNull();
});
it("returns offline for non-OK response", async () => {
const fetchFn = createMockFetch(async () =>
jsonResponse({ error: "bad" }, 500),
);
const m = mgr(fetchFn);
const profile = m.addProfile(sampleData);
const result = await m.checkHealth(profile.id);
expect(result.status).toBe("offline");
expect(typeof result.latencyMs).toBe("number");
});
it("returns offline for nonexistent profile", async () => {
const m = mgr();
const result = await m.checkHealth("nonexistent");
expect(result.status).toBe("offline");
});
it("pings the correct URL with /api/v1/health", async () => {
let capturedUrl = "";
const fetchFn = createMockFetch(async (url) => {
capturedUrl = url;
return jsonResponse({ version: "1.0.0" });
});
const m = mgr(fetchFn);
const profile = m.addProfile(sampleData);
await m.checkHealth(profile.id);
expect(capturedUrl).toBe("https://localhost:8443/api/v1/health");
});
it("uses AbortController signal in fetch call", async () => {
let capturedSignal: AbortSignal | undefined;
const fetchFn = createMockFetch(async (_url, init) => {
capturedSignal = init?.signal ?? undefined;
return jsonResponse({ version: "1.0.0" });
});
const m = mgr(fetchFn);
const profile = m.addProfile(sampleData);
await m.checkHealth(profile.id);
expect(capturedSignal).toBeInstanceOf(AbortSignal);
});
it("checkAllHealth pings all profiles in parallel", async () => {
const pingedHosts: string[] = [];
const fetchFn = createMockFetch(async (url) => {
pingedHosts.push(url);
return jsonResponse({ version: "2.0.0" });
});
const m = mgr(fetchFn);
const p1 = m.addProfile(sampleData);
const p2 = m.addProfile(sampleData2);
const results = await m.checkAllHealth();
expect(results.size).toBe(2);
expect(results.get(p1.id)?.status).toBe("online");
expect(results.get(p2.id)?.status).toBe("online");
expect(pingedHosts).toHaveLength(2);
expect(pingedHosts).toContain("https://localhost:8443/api/v1/health");
expect(pingedHosts).toContain(
"https://prod.example.com:443/api/v1/health",
);
});
it("checkAllHealth returns empty map when no profiles", async () => {
const m = mgr();
const results = await m.checkAllHealth();
expect(results.size).toBe(0);
});
});
// 4. update modifies fields immutably
it("updates a profile immutably", () => {
const mgr = createProfileManager(backend);
const original = mgr.create(sampleData);
// ── Export / Import ──────────────────────────────────────
const updated = mgr.update(original.id, { name: "Renamed" });
describe("export and import", () => {
it("round-trips profiles through export and import", () => {
const m1 = mgr();
m1.addProfile(sampleData);
m1.addProfile(sampleData2);
expect(updated).not.toBeNull();
expect(updated!.name).toBe("Renamed");
expect(updated!.host).toBe(original.host);
// Original object should not have been mutated
expect(original.name).toBe("Dev Server");
// The stored profile should be the updated one
expect(mgr.getById(original.id)!.name).toBe("Renamed");
const exported = m1.exportProfiles();
const backend2 = createMockBackend();
const m2 = createProfileManager(
backend2,
mockFetch as unknown as FetchFn,
);
const result = m2.importProfiles(exported);
expect(result.imported).toBe(2);
expect(result.skipped).toBe(0);
expect(m2.getAll()).toHaveLength(2);
const hosts = m2.getAll().map((p) => p.host);
expect(hosts).toContain("localhost:8443");
expect(hosts).toContain("prod.example.com:443");
});
it("skips duplicate hosts during import", () => {
const m = mgr();
m.addProfile(sampleData);
const incoming: ServerProfile[] = [
{
id: "ext-1",
name: "Duplicate",
host: "localhost:8443",
username: "charlie",
color: "#000000",
autoConnect: false,
lastConnected: null,
},
{
id: "ext-2",
name: "New Server",
host: "new.example.com:443",
username: "dave",
color: "#ffffff",
autoConnect: false,
lastConnected: null,
},
];
const result = m.importProfiles(JSON.stringify(incoming));
expect(result.imported).toBe(1);
expect(result.skipped).toBe(1);
expect(m.getAll()).toHaveLength(2);
});
it("handles invalid JSON gracefully", () => {
const m = mgr();
const result = m.importProfiles("not json");
expect(result).toEqual({ imported: 0, skipped: 0 });
});
it("handles non-array, non-envelope JSON gracefully", () => {
const m = mgr();
const result = m.importProfiles(JSON.stringify({ foo: "bar" }));
expect(result).toEqual({ imported: 0, skipped: 0 });
});
it("rejects import entries with invalid shape", () => {
const m = mgr();
const badEntries = [
{ id: "x", name: "", host: "a", username: "b", color: "#000", autoConnect: false, lastConnected: null },
{ id: "y", name: "Valid", host: "valid.com:443", username: "u", color: "#fff", autoConnect: false, lastConnected: null },
];
const result = m.importProfiles(JSON.stringify(badEntries));
expect(result.imported).toBe(1);
expect(result.skipped).toBe(1);
});
it("exported data includes schema version", () => {
const m = mgr();
m.addProfile(sampleData);
const exported = JSON.parse(m.exportProfiles());
expect(exported.schemaVersion).toBe(1);
expect(Array.isArray(exported.profiles)).toBe(true);
});
it("imports new UUIDs rather than keeping originals", () => {
const m1 = mgr();
const created = m1.addProfile(sampleData);
const exported = m1.exportProfiles();
const backend2 = createMockBackend();
const m2 = createProfileManager(
backend2,
mockFetch as unknown as FetchFn,
);
m2.importProfiles(exported);
const imported = m2.getAll();
expect(imported).toHaveLength(1);
// The imported profile should have a NEW UUID
expect(imported[0]!.id).not.toBe(created.id);
});
});
it("returns null when updating a nonexistent profile", () => {
const mgr = createProfileManager(backend);
expect(mgr.update("missing", { name: "X" })).toBeNull();
});
// ── Persistence ──────────────────────────────────────────
// 5. remove deletes a profile
it("removes an existing profile", () => {
const mgr = createProfileManager(backend);
const profile = mgr.create(sampleData);
expect(mgr.remove(profile.id)).toBe(true);
expect(mgr.getAll()).toHaveLength(0);
expect(mgr.getById(profile.id)).toBeNull();
});
it("returns false when removing a nonexistent profile", () => {
const mgr = createProfileManager(backend);
expect(mgr.remove("missing")).toBe(false);
});
// 6. setLastConnected updates timestamp
it("sets lastConnected to current ISO timestamp", () => {
const mgr = createProfileManager(backend);
const profile = mgr.create(sampleData);
const before = new Date().toISOString();
mgr.setLastConnected(profile.id);
const after = new Date().toISOString();
const updated = mgr.getById(profile.id)!;
expect(updated.lastConnected).not.toBeNull();
expect(updated.lastConnected! >= before).toBe(true);
expect(updated.lastConnected! <= after).toBe(true);
// Original object not mutated
expect(profile.lastConnected).toBeNull();
});
it("does nothing when setting lastConnected on nonexistent profile", () => {
const mgr = createProfileManager(backend);
// Should not throw
mgr.setLastConnected("missing");
});
// 7. getAutoConnect returns first auto-connect or null
it("returns first auto-connect profile", () => {
const mgr = createProfileManager(backend);
mgr.create(sampleData); // autoConnect: false
const autoProfile = mgr.create(sampleData2); // autoConnect: true
expect(mgr.getAutoConnect()).toEqual(autoProfile);
});
it("returns null when no profiles have autoConnect", () => {
const mgr = createProfileManager(backend);
mgr.create(sampleData);
expect(mgr.getAutoConnect()).toBeNull();
});
// 8. exportProfiles / importProfiles round-trip
it("round-trips profiles through export and import", () => {
const mgr1 = createProfileManager(backend);
mgr1.create(sampleData);
mgr1.create(sampleData2);
const exported = mgr1.exportProfiles();
const backend2 = createMockBackend();
const mgr2 = createProfileManager(backend2);
const result = mgr2.importProfiles(exported);
expect(result.imported).toBe(2);
expect(result.skipped).toBe(0);
expect(mgr2.getAll()).toHaveLength(2);
// Imported profiles get new UUIDs
const hosts = mgr2.getAll().map((p) => p.host);
expect(hosts).toContain("localhost:8443");
expect(hosts).toContain("prod.example.com:443");
});
// 9. importProfiles skips duplicates by host
it("skips duplicate hosts during import", () => {
const mgr = createProfileManager(backend);
mgr.create(sampleData);
const incoming: ServerProfile[] = [
{
id: "ext-1",
name: "Duplicate",
host: "localhost:8443",
username: "charlie",
color: "#000000",
autoConnect: false,
lastConnected: null,
describe("persistence", () => {
it("loadProfiles populates store from backend", async () => {
// Pre-seed the backend
await backend.save({
schemaVersion: 1,
},
{
id: "ext-2",
name: "New Server",
host: "new.example.com:443",
username: "dave",
color: "#ffffff",
autoConnect: false,
lastConnected: null,
schemaVersion: 1,
},
];
profiles: [
{
id: "persisted-1",
name: "Saved Server",
host: "saved.example.com:443",
username: "eve",
color: "#112233",
autoConnect: false,
lastConnected: "2026-01-01T00:00:00.000Z",
},
],
});
const result = mgr.importProfiles(JSON.stringify(incoming));
const m = mgr();
await m.loadProfiles();
expect(result.imported).toBe(1);
expect(result.skipped).toBe(1);
expect(mgr.getAll()).toHaveLength(2);
expect(m.getAll()).toHaveLength(1);
expect(m.getAll()[0]!.name).toBe("Saved Server");
});
it("saveProfiles writes current state with schema version to backend", async () => {
const m = mgr();
m.addProfile(sampleData);
await m.saveProfiles();
expect(backend.saved).toHaveLength(1);
expect(backend.saved[0]!.schemaVersion).toBe(1);
expect(backend.saved[0]!.profiles).toHaveLength(1);
expect(backend.saved[0]!.profiles[0]!.name).toBe("Dev Server");
});
it("loadProfiles handles empty backend gracefully", async () => {
const m = mgr();
await m.loadProfiles();
expect(m.getAll()).toEqual([]);
});
});
it("handles invalid JSON gracefully on import", () => {
const mgr = createProfileManager(backend);
const result = mgr.importProfiles("not json");
expect(result).toEqual({ imported: 0, skipped: 0 });
});
// ── Reactive store ───────────────────────────────────────
it("handles non-array JSON gracefully on import", () => {
const mgr = createProfileManager(backend);
const result = mgr.importProfiles(JSON.stringify({ foo: "bar" }));
expect(result).toEqual({ imported: 0, skipped: 0 });
});
describe("reactive store", () => {
it("notifies subscribers on profile add", () => {
const m = mgr();
const states: ProfilesState[] = [];
m.store.subscribe((s) => states.push(s));
// 10. Persistence: new manager with same backend loads existing data
it("persists profiles across manager instances", () => {
const mgr1 = createProfileManager(backend);
const created = mgr1.create(sampleData);
m.addProfile(sampleData);
// Create a second manager with the same backend
const mgr2 = createProfileManager(backend);
const loaded = mgr2.getAll();
expect(states).toHaveLength(1);
expect(states[0]!.profiles).toHaveLength(1);
});
expect(loaded).toHaveLength(1);
expect(loaded[0]).toEqual(created);
});
it("notifies subscribers on profile remove", () => {
const m = mgr();
const profile = m.addProfile(sampleData);
// migrate is currently a no-op
it("migrate runs without error", () => {
const mgr = createProfileManager(backend);
expect(() => mgr.migrate()).not.toThrow();
const states: ProfilesState[] = [];
m.store.subscribe((s) => states.push(s));
m.removeProfile(profile.id);
expect(states).toHaveLength(1);
expect(states[0]!.profiles).toHaveLength(0);
});
it("healthStatuses updates are visible via store", async () => {
const fetchFn = createMockFetch(async () =>
jsonResponse({ version: "3.0.0" }),
);
const m = mgr(fetchFn);
const profile = m.addProfile(sampleData);
await m.checkHealth(profile.id);
const statuses = m.store.getState().healthStatuses;
expect(statuses.get(profile.id)?.status).toBe("online");
expect(statuses.get(profile.id)?.version).toBe("3.0.0");
});
});
});
@@ -1,124 +1,298 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RateLimiter, createRateLimiterSet } from '../../src/lib/rate-limiter';
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
RateLimiter,
createRateLimiter,
createRateLimiterSet,
createTypingLimiter,
createPresenceLimiter,
createReactionLimiter,
createVoiceLimiter,
createSoundboardLimiter,
createChatLimiter,
createVideoCameraLimiter,
} from "@lib/rate-limiter";
describe('RateLimiter', () => {
// ---------------------------------------------------------------------------
// Core RateLimiter behaviour
// ---------------------------------------------------------------------------
describe("RateLimiter", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('allows requests under limit', () => {
const limiter = new RateLimiter(3, 1_000);
expect(limiter.tryConsume('a')).toBe(true);
expect(limiter.tryConsume('a')).toBe(true);
expect(limiter.tryConsume('a')).toBe(true);
afterEach(() => {
vi.useRealTimers();
});
it('blocks requests at limit', () => {
const limiter = new RateLimiter(2, 1_000);
expect(limiter.tryConsume('a')).toBe(true);
expect(limiter.tryConsume('a')).toBe(true);
expect(limiter.tryConsume('a')).toBe(false);
// -- Construction ---------------------------------------------------------
it("throws when maxTokens < 1", () => {
expect(() => new RateLimiter({ maxTokens: 0, windowMs: 1_000 })).toThrow(
"maxTokens must be >= 1",
);
});
it('resets after window expires', () => {
const limiter = new RateLimiter(1, 1_000);
expect(limiter.tryConsume('a')).toBe(true);
expect(limiter.tryConsume('a')).toBe(false);
it("throws when windowMs < 1", () => {
expect(() => new RateLimiter({ maxTokens: 1, windowMs: 0 })).toThrow(
"windowMs must be >= 1",
);
});
// -- tryConsume -----------------------------------------------------------
it("allows requests under the limit", () => {
const limiter = createRateLimiter(3, 1_000);
expect(limiter.tryConsume("a")).toBe(true);
expect(limiter.tryConsume("a")).toBe(true);
expect(limiter.tryConsume("a")).toBe(true);
});
it("blocks rapid-fire requests that exceed the limit", () => {
const limiter = createRateLimiter(2, 1_000);
expect(limiter.tryConsume("a")).toBe(true);
expect(limiter.tryConsume("a")).toBe(true);
expect(limiter.tryConsume("a")).toBe(false);
expect(limiter.tryConsume("a")).toBe(false);
});
it("uses a default key when key is omitted", () => {
const limiter = createRateLimiter(1, 1_000);
expect(limiter.tryConsume()).toBe(true);
expect(limiter.tryConsume()).toBe(false);
});
// -- Per-key isolation ----------------------------------------------------
it("isolates different keys", () => {
const limiter = createRateLimiter(1, 1_000);
expect(limiter.tryConsume("key1")).toBe(true);
expect(limiter.tryConsume("key2")).toBe(true);
// Both should be individually exhausted
expect(limiter.tryConsume("key1")).toBe(false);
expect(limiter.tryConsume("key2")).toBe(false);
});
// -- Window expiry --------------------------------------------------------
it("allows new requests after window expires", () => {
const limiter = createRateLimiter(1, 1_000);
expect(limiter.tryConsume("a")).toBe(true);
expect(limiter.tryConsume("a")).toBe(false);
vi.advanceTimersByTime(1_001);
expect(limiter.tryConsume('a')).toBe(true);
expect(limiter.tryConsume("a")).toBe(true);
});
it('isolates different keys', () => {
const limiter = new RateLimiter(1, 1_000);
expect(limiter.tryConsume('key1')).toBe(true);
expect(limiter.tryConsume('key2')).toBe(true);
expect(limiter.tryConsume('key1')).toBe(false);
expect(limiter.tryConsume('key2')).toBe(false);
it("sliding window allows staggered requests", () => {
const limiter = createRateLimiter(2, 1_000);
// t=0: consume first
expect(limiter.tryConsume("a")).toBe(true);
// t=500: consume second
vi.advanceTimersByTime(500);
expect(limiter.tryConsume("a")).toBe(true);
// t=500: blocked (2 within window)
expect(limiter.tryConsume("a")).toBe(false);
// t=1001: first request expired, slot opens
vi.advanceTimersByTime(501);
expect(limiter.tryConsume("a")).toBe(true);
});
it('reset(key) clears state for a specific key', () => {
const limiter = new RateLimiter(1, 1_000);
expect(limiter.tryConsume('a')).toBe(true);
expect(limiter.tryConsume('b')).toBe(true);
expect(limiter.tryConsume('a')).toBe(false);
// -- reset ----------------------------------------------------------------
limiter.reset('a');
it("reset(key) clears state for a specific key only", () => {
const limiter = createRateLimiter(1, 1_000);
expect(limiter.tryConsume("a")).toBe(true);
expect(limiter.tryConsume("b")).toBe(true);
expect(limiter.tryConsume("a")).toBe(false);
expect(limiter.tryConsume('a')).toBe(true);
// 'b' should still be blocked
expect(limiter.tryConsume('b')).toBe(false);
limiter.reset("a");
expect(limiter.tryConsume("a")).toBe(true);
// "b" should still be blocked
expect(limiter.tryConsume("b")).toBe(false);
});
it('reset() without key clears all state', () => {
const limiter = new RateLimiter(1, 1_000);
expect(limiter.tryConsume('a')).toBe(true);
expect(limiter.tryConsume('b')).toBe(true);
it("reset() without key clears the default key only", () => {
const limiter = createRateLimiter(1, 1_000);
expect(limiter.tryConsume()).toBe(true);
expect(limiter.tryConsume()).toBe(false);
limiter.reset();
expect(limiter.tryConsume('a')).toBe(true);
expect(limiter.tryConsume('b')).toBe(true);
expect(limiter.tryConsume()).toBe(true);
});
it('getRemainingMs returns 0 when allowed', () => {
const limiter = new RateLimiter(5, 1_000);
expect(limiter.getRemainingMs('a')).toBe(0);
// -- resetAll -------------------------------------------------------------
it("resetAll() clears all keys", () => {
const limiter = createRateLimiter(1, 1_000);
expect(limiter.tryConsume("a")).toBe(true);
expect(limiter.tryConsume("b")).toBe(true);
expect(limiter.tryConsume("a")).toBe(false);
expect(limiter.tryConsume("b")).toBe(false);
limiter.resetAll();
expect(limiter.tryConsume("a")).toBe(true);
expect(limiter.tryConsume("b")).toBe(true);
});
it('getRemainingMs returns positive value when blocked', () => {
const limiter = new RateLimiter(1, 1_000);
limiter.tryConsume('a');
// -- getRemainingMs -------------------------------------------------------
const remaining = limiter.getRemainingMs('a');
it("getRemainingMs returns 0 when under limit", () => {
const limiter = createRateLimiter(5, 1_000);
expect(limiter.getRemainingMs("a")).toBe(0);
});
it("getRemainingMs returns positive value when blocked", () => {
const limiter = createRateLimiter(1, 1_000);
limiter.tryConsume("a");
const remaining = limiter.getRemainingMs("a");
expect(remaining).toBeGreaterThan(0);
expect(remaining).toBeLessThanOrEqual(1_000);
});
it("getRemainingMs uses default key when omitted", () => {
const limiter = createRateLimiter(1, 1_000);
limiter.tryConsume();
expect(limiter.getRemainingMs()).toBeGreaterThan(0);
});
});
describe('createRateLimiterSet', () => {
it('returns all expected keys', () => {
const set = createRateLimiterSet();
const expectedKeys = [
'chat',
'typing',
'presence',
'reactions',
'voice',
'voiceVideo',
'soundboard',
];
for (const key of expectedKeys) {
expect(set).toHaveProperty(key);
expect(set[key as keyof typeof set]).toBeInstanceOf(RateLimiter);
}
// ---------------------------------------------------------------------------
// Factory functions
// ---------------------------------------------------------------------------
describe("createRateLimiter", () => {
it("creates a limiter with the specified config", () => {
const limiter = createRateLimiter(3, 500);
expect(limiter).toBeInstanceOf(RateLimiter);
// Verify the config by consuming exactly 3 tokens
expect(limiter.tryConsume()).toBe(true);
expect(limiter.tryConsume()).toBe(true);
expect(limiter.tryConsume()).toBe(true);
expect(limiter.tryConsume()).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Pre-configured protocol limiters
// ---------------------------------------------------------------------------
describe("Pre-configured limiters", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('typing limiter blocks at 1/3s rate', () => {
vi.useFakeTimers();
const set = createRateLimiterSet();
expect(set.typing.tryConsume('chan:5')).toBe(true);
expect(set.typing.tryConsume('chan:5')).toBe(false);
vi.advanceTimersByTime(2_999);
expect(set.typing.tryConsume('chan:5')).toBe(false);
vi.advanceTimersByTime(2);
expect(set.typing.tryConsume('chan:5')).toBe(true);
afterEach(() => {
vi.useRealTimers();
});
it('chat limiter allows 10/sec', () => {
vi.useFakeTimers();
const set = createRateLimiterSet();
it("createChatLimiter: 10 per 1s", () => {
const limiter = createChatLimiter();
for (let i = 0; i < 10; i++) {
expect(set.chat.tryConsume('user:1')).toBe(true);
expect(limiter.tryConsume("user:1")).toBe(true);
}
expect(set.chat.tryConsume('user:1')).toBe(false);
expect(limiter.tryConsume("user:1")).toBe(false);
vi.advanceTimersByTime(1_001);
expect(set.chat.tryConsume('user:1')).toBe(true);
expect(limiter.tryConsume("user:1")).toBe(true);
});
it("createTypingLimiter: 1 per 3s", () => {
const limiter = createTypingLimiter();
expect(limiter.tryConsume("chan:5")).toBe(true);
expect(limiter.tryConsume("chan:5")).toBe(false);
// Still blocked just before 3s
vi.advanceTimersByTime(2_999);
expect(limiter.tryConsume("chan:5")).toBe(false);
// Allowed after 3s
vi.advanceTimersByTime(2);
expect(limiter.tryConsume("chan:5")).toBe(true);
});
it("createPresenceLimiter: 1 per 10s", () => {
const limiter = createPresenceLimiter();
expect(limiter.tryConsume()).toBe(true);
expect(limiter.tryConsume()).toBe(false);
vi.advanceTimersByTime(10_001);
expect(limiter.tryConsume()).toBe(true);
});
it("createReactionLimiter: 5 per 1s", () => {
const limiter = createReactionLimiter();
for (let i = 0; i < 5; i++) {
expect(limiter.tryConsume()).toBe(true);
}
expect(limiter.tryConsume()).toBe(false);
vi.advanceTimersByTime(1_001);
expect(limiter.tryConsume()).toBe(true);
});
it("createVoiceLimiter: 20 per 1s", () => {
const limiter = createVoiceLimiter();
for (let i = 0; i < 20; i++) {
expect(limiter.tryConsume()).toBe(true);
}
expect(limiter.tryConsume()).toBe(false);
vi.advanceTimersByTime(1_001);
expect(limiter.tryConsume()).toBe(true);
});
it("createVideoCameraLimiter: 2 per 1s", () => {
const limiter = createVideoCameraLimiter();
expect(limiter.tryConsume()).toBe(true);
expect(limiter.tryConsume()).toBe(true);
expect(limiter.tryConsume()).toBe(false);
});
it("createSoundboardLimiter: 1 per 3s", () => {
const limiter = createSoundboardLimiter();
expect(limiter.tryConsume()).toBe(true);
expect(limiter.tryConsume()).toBe(false);
vi.advanceTimersByTime(3_001);
expect(limiter.tryConsume()).toBe(true);
});
});
// ---------------------------------------------------------------------------
// RateLimiterSet
// ---------------------------------------------------------------------------
describe("createRateLimiterSet", () => {
it("returns all expected limiter keys", () => {
const set = createRateLimiterSet();
const expectedKeys = [
"chat",
"typing",
"presence",
"reactions",
"voice",
"voiceVideo",
"soundboard",
] as const;
for (const key of expectedKeys) {
expect(set[key]).toBeInstanceOf(RateLimiter);
}
});
it("returns frozen object", () => {
const set = createRateLimiterSet();
expect(Object.isFrozen(set)).toBe(true);
});
});
@@ -0,0 +1,374 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createSettingsOverlay } from "@components/SettingsOverlay";
// Mock logger
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
getLogBuffer: () => [],
clearLogBuffer: vi.fn(),
addLogListener: () => () => {},
setLogLevel: vi.fn(),
}));
// Mock stores
vi.mock("@stores/ui.store", () => ({
uiStore: {
getState: () => ({ settingsOpen: false }),
subscribe: () => () => {},
},
}));
vi.mock("@stores/auth.store", () => ({
authStore: {
getState: () => ({
user: { id: 1, username: "testuser" },
}),
},
}));
function clickEl(el: Element | null): void {
expect(el).not.toBeNull();
(el as HTMLElement).click();
}
function getTab(container: HTMLDivElement, index: number): HTMLElement {
const tabs = container.querySelectorAll(".settings-sidebar > button.settings-nav-item");
const tab = tabs[index];
expect(tab).toBeDefined();
return tab as HTMLElement;
}
describe("SettingsOverlay", () => {
let container: HTMLDivElement;
const defaultOptions = {
onClose: vi.fn(),
onChangePassword: vi.fn().mockResolvedValue(undefined),
onUpdateProfile: vi.fn().mockResolvedValue(undefined),
onLogout: vi.fn(),
};
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
localStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
container.remove();
});
it("mounts with all tabs", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
const tabs = container.querySelectorAll(".settings-sidebar > button.settings-nav-item");
const tabNames = Array.from(tabs).map((t) => t.textContent);
expect(tabNames).toEqual([
"Account",
"Appearance",
"Notifications",
"Voice & Audio",
"Keybinds",
"Logs",
]);
overlay.destroy?.();
});
it("starts on Account tab", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
const activeTab = container.querySelector(".settings-sidebar > button.settings-nav-item.active");
expect(activeTab?.textContent).toBe("Account");
overlay.destroy?.();
});
it("switches tabs on click", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
const appearanceTab = getTab(container, 1);
appearanceTab.click();
expect(appearanceTab.classList.contains("active")).toBe(true);
const prevActive = getTab(container, 0);
expect(prevActive.classList.contains("active")).toBe(false);
overlay.destroy?.();
});
it("renders close button that calls onClose", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
clickEl(container.querySelector(".settings-close-btn"));
expect(defaultOptions.onClose).toHaveBeenCalled();
overlay.destroy?.();
});
it("closes on Escape key", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
overlay.open();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(defaultOptions.onClose).toHaveBeenCalled();
overlay.destroy?.();
});
// --- Appearance tab tests ---
it("applies theme on click", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
getTab(container, 1).click();
const themeOptions = container.querySelectorAll(".theme-opt");
expect(themeOptions.length).toBe(3);
const midnight = themeOptions[1] as HTMLElement;
midnight.click();
expect(midnight.classList.contains("active")).toBe(true);
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#1a1a2e");
expect(localStorage.getItem("owncord:settings:theme")).toBe('"midnight"');
overlay.destroy?.();
});
it("persists and restores font size", () => {
localStorage.setItem("owncord:settings:fontSize", "18");
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
getTab(container, 1).click();
const slider = container.querySelector(".settings-slider") as HTMLInputElement;
expect(slider.value).toBe("18");
expect(document.documentElement.style.getPropertyValue("--font-size")).toBe("18px");
overlay.destroy?.();
});
it("changes font size via slider", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
getTab(container, 1).click();
const slider = container.querySelector(".settings-slider") as HTMLInputElement;
slider.value = "14";
slider.dispatchEvent(new Event("input"));
expect(document.documentElement.style.getPropertyValue("--font-size")).toBe("14px");
expect(localStorage.getItem("owncord:settings:fontSize")).toBe("14");
overlay.destroy?.();
});
it("toggles compact mode", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
getTab(container, 1).click();
const toggle = container.querySelector(".toggle") as HTMLElement;
expect(toggle).not.toBeNull();
toggle.click();
expect(toggle.classList.contains("on")).toBe(true);
expect(document.documentElement.classList.contains("compact-mode")).toBe(true);
expect(localStorage.getItem("owncord:settings:compactMode")).toBe("true");
overlay.destroy?.();
});
// --- Notifications tab tests ---
it("renders notification toggles", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
getTab(container, 2).click();
const toggles = container.querySelectorAll(".toggle");
expect(toggles.length).toBe(4);
overlay.destroy?.();
});
it("persists notification toggle state", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
getTab(container, 2).click();
const toggles = container.querySelectorAll(".toggle");
const suppressToggle = toggles[2] as HTMLElement;
suppressToggle.click();
expect(suppressToggle.classList.contains("on")).toBe(true);
expect(localStorage.getItem("owncord:settings:suppressEveryone")).toBe("true");
overlay.destroy?.();
});
// --- Voice & Audio tab tests ---
it("renders Voice & Audio tab with device selectors", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
getTab(container, 3).click();
const selects = container.querySelectorAll("select.form-input");
expect(selects.length).toBe(2);
const sliders = container.querySelectorAll(".settings-slider");
expect(sliders.length).toBeGreaterThanOrEqual(1);
const toggles = container.querySelectorAll(".toggle");
expect(toggles.length).toBe(3);
overlay.destroy?.();
});
it("persists voice sensitivity setting", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
getTab(container, 3).click();
const slider = container.querySelector(".settings-slider") as HTMLInputElement;
slider.value = "75";
slider.dispatchEvent(new Event("input"));
expect(localStorage.getItem("owncord:settings:voiceSensitivity")).toBe("75");
overlay.destroy?.();
});
it("persists audio device selection on change", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
getTab(container, 3).click();
const selects = container.querySelectorAll("select.form-input");
const inputSelect = selects[0] as HTMLSelectElement;
inputSelect.dispatchEvent(new Event("change"));
expect(localStorage.getItem("owncord:settings:audioInputDevice")).toBe('""');
overlay.destroy?.();
});
it("toggles echo cancellation", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
getTab(container, 3).click();
const toggles = container.querySelectorAll(".toggle");
const echoToggle = toggles[0] as HTMLElement;
// Default is on
expect(echoToggle.classList.contains("on")).toBe(true);
echoToggle.click();
expect(echoToggle.classList.contains("on")).toBe(false);
expect(localStorage.getItem("owncord:settings:echoCancellation")).toBe("false");
overlay.destroy?.();
});
// --- Account tab tests ---
it("shows current username", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
const acName = container.querySelector(".ac-name");
expect(acName?.textContent).toBe("testuser");
overlay.destroy?.();
});
it("calls onLogout when logout button clicked", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
clickEl(container.querySelector(".settings-nav-item.danger"));
expect(defaultOptions.onLogout).toHaveBeenCalled();
overlay.destroy?.();
});
it("validates password change requires minimum length", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
const inputs = container.querySelectorAll("input[type='password']");
(inputs[0] as HTMLInputElement).value = "oldpass123";
(inputs[1] as HTMLInputElement).value = "short";
(inputs[2] as HTMLInputElement).value = "short";
const changePwBtn = Array.from(container.querySelectorAll(".ac-btn"))
.find((b) => b.textContent === "Change Password") as HTMLElement;
changePwBtn.click();
expect(defaultOptions.onChangePassword).not.toHaveBeenCalled();
overlay.destroy?.();
});
it("validates password confirmation matches", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
const inputs = container.querySelectorAll("input[type='password']");
(inputs[0] as HTMLInputElement).value = "oldpass123";
(inputs[1] as HTMLInputElement).value = "newpassword123";
(inputs[2] as HTMLInputElement).value = "differentpassword";
const changePwBtn = Array.from(container.querySelectorAll(".ac-btn"))
.find((b) => b.textContent === "Change Password") as HTMLElement;
changePwBtn.click();
expect(defaultOptions.onChangePassword).not.toHaveBeenCalled();
overlay.destroy?.();
});
// --- Open/Close ---
it("open() adds .open class, close() removes it", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
const root = container.querySelector(".settings-overlay");
expect(root?.classList.contains("open")).toBe(false);
overlay.open();
expect(root?.classList.contains("open")).toBe(true);
overlay.close();
expect(root?.classList.contains("open")).toBe(false);
overlay.destroy?.();
});
// --- Cleanup ---
it("destroy removes root from DOM", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
expect(container.querySelector(".settings-overlay")).not.toBeNull();
overlay.destroy?.();
expect(container.querySelector(".settings-overlay")).toBeNull();
});
});
@@ -0,0 +1,34 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock logger
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
// Mock Tauri APIs as unavailable by default
vi.mock("@tauri-apps/api/core", () => {
throw new Error("Not in Tauri");
});
vi.mock("@tauri-apps/api/window", () => {
throw new Error("Not in Tauri");
});
describe("window-state", () => {
beforeEach(() => {
vi.resetModules();
});
it("initWindowState returns a cleanup function when Tauri unavailable", async () => {
const { initWindowState } = await import("@lib/window-state");
const cleanup = await initWindowState();
expect(typeof cleanup).toBe("function");
// Should be a no-op
cleanup();
});
});
+3
View File
@@ -38,5 +38,8 @@
"include": [
"src",
"tests"
],
"exclude": [
"tests/e2e"
]
}