mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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 can’t 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 doesn’t 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.
|
||||
Generated
+64
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+79
-14
@@ -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",
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -38,5 +38,8 @@
|
||||
"include": [
|
||||
"src",
|
||||
"tests"
|
||||
],
|
||||
"exclude": [
|
||||
"tests/e2e"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user