diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 279b1b65..28480224 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,6 +171,87 @@ jobs: path: Client/tauri-client/coverage/ retention-days: 7 + # Rust unit tests used to live inside tauri-build, which only runs on PRs to + # main — so #[cfg(test)] code never ran on pushes or on PRs to dev, and could + # rot for a whole release cycle. This job runs them on every event. Clippy is + # run with --all-targets here (tauri-build's lib-only clippy skips test code). + rust-tests: + name: Rust Unit Tests + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: Client/tauri-client/src-tauri/ + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + libsecret-1-dev \ + libasound2-dev \ + libssl-dev \ + librsvg2-dev + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: clippy + + - name: Rust cache + uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8 + with: + workspaces: Client/tauri-client/src-tauri + + - name: Clippy lint (including test targets) + run: cargo clippy --all-targets -- -D warnings + + - name: Rust unit tests + run: cargo test --lib + + # Playwright e2e against the mocked-Tauri dev server. Non-blocking for now + # (backlog #10): the suite has never run in CI, so it gets a soak period + # before it becomes a gate. Remove continue-on-error to promote it. + # The native config (playwright.config.native.ts) is deliberately not wired + # up — it needs a real server and a built desktop binary. + client-e2e: + name: Client E2E (Playwright, non-blocking) + runs-on: ubuntu-latest + continue-on-error: true + defaults: + run: + working-directory: Client/tauri-client/ + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + cache-dependency-path: Client/tauri-client/package-lock.json + + - name: Install npm dependencies + run: npm ci + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + run: npx playwright test --config=playwright.config.ts + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: playwright-report + path: | + Client/tauri-client/playwright-report/ + Client/tauri-client/test-results/ + retention-days: 7 + server-docker-build: name: Server Docker Build (verify) runs-on: ubuntu-latest @@ -270,11 +351,8 @@ jobs: working-directory: Client/tauri-client/src-tauri/ run: cargo clippy -- -D warnings - # Without this, #[cfg(test)] code is never compiled or run in CI - # (clippy above skips test targets), so Rust unit tests would rot. - - name: Rust unit tests - working-directory: Client/tauri-client/src-tauri/ - run: cargo test --lib + # Rust unit tests moved to the standalone `rust-tests` job so they run on + # every event, not just PRs to main. - name: Security audit (Rust dependencies) working-directory: Client/tauri-client/src-tauri/ diff --git a/Client/tauri-client/src-tauri/src/livekit_proxy.rs b/Client/tauri-client/src-tauri/src/livekit_proxy.rs index e5f03f3d..444e0809 100644 --- a/Client/tauri-client/src-tauri/src/livekit_proxy.rs +++ b/Client/tauri-client/src-tauri/src/livekit_proxy.rs @@ -68,6 +68,78 @@ impl LiveKitProxyState { use crate::tofu; +// --------------------------------------------------------------------------- +// Pure helpers +// +// Split out of the command / connection-handling functions so the parts that +// decide what reaches the remote server — host validation, header rewriting and +// TLS server-name selection — are reachable from unit tests without a Tauri +// runtime or a live socket. +// --------------------------------------------------------------------------- + +/// Reject `remote_host` values that could inject headers or are not plausible +/// host:port strings. +pub(crate) fn validate_remote_host(remote_host: &str) -> Result<(), String> { + // CRLF or NUL would let a caller append arbitrary headers in the rewriting + // logic below. + if remote_host.contains('\r') || remote_host.contains('\n') || remote_host.contains('\0') { + return Err("remote_host contains invalid characters".into()); + } + // Basic hostname format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6) + if !remote_host + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) + { + return Err("remote_host contains unexpected characters".into()); + } + Ok(()) +} + +/// Rewrite the `Host` and `Origin` headers of a proxied HTTP request so the +/// remote server's WebSocket origin check accepts a connection that the +/// LiveKit SDK opened against `127.0.0.1`. +/// +/// Every other line is passed through byte-for-byte, including the request +/// line and the trailing blank line that terminates the header block. +pub(crate) fn rewrite_proxy_headers(request: &str, remote_host: &str) -> String { + let mut modified = String::with_capacity(request.len() + 128); + for (i, line) in request.split("\r\n").enumerate() { + if i > 0 { + modified.push_str("\r\n"); + } + let lower = line.to_lowercase(); + if lower.starts_with("host:") { + modified.push_str("Host: "); + modified.push_str(remote_host); + } else if lower.starts_with("origin:") { + modified.push_str("Origin: https://"); + modified.push_str(remote_host); + } else { + modified.push_str(line); + } + } + modified +} + +/// Extract the TLS server name from a `host[:port]` string. +/// +/// IPv6 literals arrive bracketed (`[::1]:8443`); the brackets are stripped and +/// an IP literal becomes `ServerName::IpAddress` rather than a DNS name, since +/// rustls will not accept an address as a DNS name. +pub(crate) fn parse_server_name(remote_host: &str) -> Result, String> { + // Default to port 443 (standard HTTPS) when no port is specified — the + // server is typically behind a reverse proxy (nginx) on the standard port. + let (raw_hostname, _port) = remote_host.rsplit_once(':').unwrap_or((remote_host, "443")); + let hostname = raw_hostname.trim_start_matches('[').trim_end_matches(']'); + + if let Ok(ip) = hostname.parse::() { + Ok(ServerName::IpAddress(ip.into())) + } else { + ServerName::try_from(hostname.to_string()) + .map_err(|e| format!("invalid server name '{hostname}': {e}")) + } +} + // --------------------------------------------------------------------------- // Tauri commands // --------------------------------------------------------------------------- @@ -84,15 +156,7 @@ pub async fn start_livekit_proxy( state: tauri::State<'_, LiveKitProxyState>, remote_host: String, ) -> Result { - // Reject remote_host values containing CRLF or null bytes to prevent - // HTTP header injection in the proxy's header rewriting logic. - if remote_host.contains('\r') || remote_host.contains('\n') || remote_host.contains('\0') { - return Err("remote_host contains invalid characters".into()); - } - // Basic hostname format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6) - if !remote_host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) { - return Err("remote_host contains unexpected characters".into()); - } + validate_remote_host(&remote_host)?; let mut inner = state.inner.lock().await; @@ -266,22 +330,7 @@ async fn handle_connection( // ── 2. Rewrite Host and Origin headers ─────────────────────────────── let request = String::from_utf8_lossy(&buf); - let mut modified = String::with_capacity(buf.len() + 128); - for (i, line) in request.split("\r\n").enumerate() { - if i > 0 { - modified.push_str("\r\n"); - } - let lower = line.to_lowercase(); - if lower.starts_with("host:") { - modified.push_str("Host: "); - modified.push_str(remote_host); - } else if lower.starts_with("origin:") { - modified.push_str("Origin: https://"); - modified.push_str(remote_host); - } else { - modified.push_str(line); - } - } + let modified = rewrite_proxy_headers(&request, remote_host); // ── 3. Connect to remote over TLS ──────────────────────────────────── let tls_config = rustls::ClientConfig::builder() @@ -293,20 +342,7 @@ async fn handle_connection( let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config)); - // Parse hostname (strip brackets for IPv6, e.g. "[::1]:8443"). - // Default to port 443 (standard HTTPS) when no port is specified — the - // server is typically behind a reverse proxy (nginx) on the standard port. - let (raw_hostname, _port) = remote_host.rsplit_once(':').unwrap_or((remote_host, "443")); - let hostname = raw_hostname - .trim_start_matches('[') - .trim_end_matches(']'); - - let server_name = if let Ok(ip) = hostname.parse::() { - ServerName::IpAddress(ip.into()) - } else { - ServerName::try_from(hostname.to_string()) - .map_err(|e| format!("invalid server name '{hostname}': {e}"))? - }; + let server_name = parse_server_name(remote_host)?; debug!("[livekit_proxy] connecting TCP to {}", remote_host); let tcp = TcpStream::connect(remote_host).await?; @@ -330,3 +366,196 @@ async fn handle_connection( } // cert_store_key is covered by unit tests in the shared `tofu` module. + +// --------------------------------------------------------------------------- +// Tests (pure logic only — no Tauri runtime or live socket required) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ── validate_remote_host ──────────────────────────────────────────────── + + #[test] + fn accepts_plain_hostnames_and_ports() { + for host in [ + "example.com", + "example.com:8443", + "sub.domain.example.com", + "my-server.example.com:443", + "127.0.0.1:8443", + "[::1]:8443", + "localhost", + ] { + assert!(validate_remote_host(host).is_ok(), "should accept {host}"); + } + } + + #[test] + fn rejects_crlf_injection() { + // The classic header-injection payload: everything after the CRLF + // would land in the rewritten request as attacker-chosen headers. + for host in [ + "example.com\r\nX-Injected: 1", + "example.com\nX-Injected: 1", + "example.com\r", + ] { + assert!(validate_remote_host(host).is_err(), "should reject {host:?}"); + } + } + + #[test] + fn rejects_nul_bytes() { + assert!(validate_remote_host("example.com\0").is_err()); + } + + #[test] + fn rejects_unexpected_characters() { + for host in [ + "example.com/path", + "user@example.com", + "example.com?q=1", + "example com", + "exa mple.com:443", + "example.com;evil", + ] { + assert!(validate_remote_host(host).is_err(), "should reject {host:?}"); + } + } + + #[test] + fn accepts_empty_host() { + // Empty passes the character checks; the subsequent TCP connect is what + // fails. Pinned so a future tightening is a deliberate change. + assert!(validate_remote_host("").is_ok()); + } + + // ── rewrite_proxy_headers ─────────────────────────────────────────────── + + #[test] + fn rewrites_host_and_origin() { + let request = "GET /rtc HTTP/1.1\r\n\ + Host: 127.0.0.1:54321\r\n\ + Origin: http://127.0.0.1:54321\r\n\ + Upgrade: websocket\r\n\r\n"; + + let got = rewrite_proxy_headers(request, "chat.example.com:8443"); + + assert!(got.contains("Host: chat.example.com:8443")); + assert!(got.contains("Origin: https://chat.example.com:8443")); + assert!(!got.contains("127.0.0.1:54321")); + } + + #[test] + fn preserves_the_request_line_and_other_headers() { + let request = "GET /rtc?access_token=abc HTTP/1.1\r\n\ + Host: 127.0.0.1:1\r\n\ + Upgrade: websocket\r\n\ + Connection: Upgrade\r\n\ + Sec-WebSocket-Key: dGhlIHNhbXBsZQ==\r\n\r\n"; + + let got = rewrite_proxy_headers(request, "example.com"); + + // The token lives in the query string; losing it turns every voice + // join into an auth failure. + assert!(got.starts_with("GET /rtc?access_token=abc HTTP/1.1\r\n")); + assert!(got.contains("Upgrade: websocket")); + assert!(got.contains("Connection: Upgrade")); + assert!(got.contains("Sec-WebSocket-Key: dGhlIHNhbXBsZQ==")); + } + + #[test] + fn matches_header_names_case_insensitively() { + let request = "GET / HTTP/1.1\r\nhOsT: 127.0.0.1\r\nORIGIN: http://x\r\n\r\n"; + + let got = rewrite_proxy_headers(request, "example.com"); + + assert!(got.contains("Host: example.com")); + assert!(got.contains("Origin: https://example.com")); + assert!(!got.contains("hOsT")); + assert!(!got.contains("ORIGIN")); + } + + #[test] + fn preserves_the_terminating_blank_line() { + let request = "GET / HTTP/1.1\r\nHost: x\r\n\r\n"; + + let got = rewrite_proxy_headers(request, "example.com"); + + // Without the trailing CRLFCRLF the remote server keeps waiting for + // more headers and the handshake hangs. + assert!(got.ends_with("\r\n\r\n"), "got: {got:?}"); + } + + #[test] + fn does_not_rewrite_headers_that_merely_contain_host_or_origin() { + let request = + "GET / HTTP/1.1\r\nHost: x\r\nX-Forwarded-Host: keep.me\r\nReferer: http://o\r\n\r\n"; + + let got = rewrite_proxy_headers(request, "example.com"); + + assert!(got.contains("X-Forwarded-Host: keep.me")); + assert!(got.contains("Referer: http://o")); + } + + #[test] + fn adds_no_headers_when_none_are_present() { + let request = "GET / HTTP/1.1\r\nUpgrade: websocket\r\n\r\n"; + + let got = rewrite_proxy_headers(request, "example.com"); + + // The rewriter only replaces; it never synthesises a Host header. + assert_eq!(got, request); + } + + #[test] + fn rewrites_every_occurrence() { + let request = "GET / HTTP/1.1\r\nHost: a\r\nHost: b\r\n\r\n"; + + let got = rewrite_proxy_headers(request, "example.com"); + + assert_eq!(got.matches("Host: example.com").count(), 2); + } + + // ── parse_server_name ─────────────────────────────────────────────────── + + #[test] + fn parses_a_dns_name_without_a_port() { + let got = parse_server_name("example.com").expect("should parse"); + assert!(matches!(got, ServerName::DnsName(_))); + } + + #[test] + fn parses_a_dns_name_with_a_port() { + let got = parse_server_name("example.com:8443").expect("should parse"); + match got { + ServerName::DnsName(d) => assert_eq!(d.as_ref(), "example.com"), + other => panic!("expected DnsName, got {other:?}"), + } + } + + #[test] + fn parses_an_ipv4_literal_as_an_address() { + // rustls rejects an IP supplied as a DNS name, so the branch matters. + let got = parse_server_name("127.0.0.1:8443").expect("should parse"); + assert!(matches!(got, ServerName::IpAddress(_))); + } + + #[test] + fn parses_a_bracketed_ipv6_literal_as_an_address() { + let got = parse_server_name("[::1]:8443").expect("should parse"); + assert!(matches!(got, ServerName::IpAddress(_))); + } + + #[test] + fn parses_a_bare_ipv4_literal() { + let got = parse_server_name("10.0.0.5").expect("should parse"); + assert!(matches!(got, ServerName::IpAddress(_))); + } + + #[test] + fn rejects_an_invalid_dns_name() { + assert!(parse_server_name("not a hostname").is_err()); + } +} diff --git a/Client/tauri-client/src-tauri/src/ptt.rs b/Client/tauri-client/src-tauri/src/ptt.rs index 23fd3b40..2802a28c 100644 --- a/Client/tauri-client/src-tauri/src/ptt.rs +++ b/Client/tauri-client/src-tauri/src/ptt.rs @@ -545,7 +545,7 @@ mod tests { assert_eq!(keycode_to_vk(&keycode), vk, "keycode_to_vk failed for {keycode:?}"); assert_eq!( vk_to_keycode(vk), - Some(keycode.clone()), + Some(keycode), "vk_to_keycode failed for vk={vk:#04x}" ); } diff --git a/Client/tauri-client/src-tauri/src/ws_proxy.rs b/Client/tauri-client/src-tauri/src/ws_proxy.rs index a66bf84e..b8720915 100644 --- a/Client/tauri-client/src-tauri/src/ws_proxy.rs +++ b/Client/tauri-client/src-tauri/src/ws_proxy.rs @@ -282,6 +282,23 @@ pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), Strin Ok(()) } +/// Whether `fingerprint` is a SHA-256 digest in colon-hex form: +/// `XX:XX:XX:...`, 32 hex pairs separated by colons (95 chars total). +/// +/// Split out of `accept_cert_fingerprint` so the format check — the guard on +/// the only code path that writes a cert pin — is reachable from unit tests +/// without a Tauri runtime. +pub(crate) fn is_valid_cert_fingerprint(fingerprint: &str) -> bool { + fingerprint.len() == 95 + && fingerprint.bytes().enumerate().all(|(i, b)| { + if (i + 1) % 3 == 0 { + b == b':' + } else { + b.is_ascii_hexdigit() + } + }) +} + /// Accept a certificate fingerprint for a host — the ONLY path that writes a pin. /// Called after the user acknowledges a first-use or cert-mismatch prompt. #[tauri::command] @@ -294,16 +311,7 @@ pub fn accept_cert_fingerprint( return Err("host and fingerprint must not be empty".into()); } - // Validate SHA-256 colon-hex format: XX:XX:XX:... (32 pairs = 95 chars) - let valid = fingerprint.len() == 95 - && fingerprint.bytes().enumerate().all(|(i, b)| { - if (i + 1) % 3 == 0 { - b == b':' - } else { - b.is_ascii_hexdigit() - } - }); - if !valid { + if !is_valid_cert_fingerprint(&fingerprint) { return Err("fingerprint must be SHA-256 colon-hex format (e.g. aa:bb:cc:...)".into()); } @@ -338,3 +346,81 @@ pub fn accept_cert_fingerprint( } Ok(()) } + +// --------------------------------------------------------------------------- +// Tests (pure logic only — no Tauri runtime required) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// A well-formed SHA-256 colon-hex fingerprint (32 pairs, 95 chars). + const VALID: &str = "e3:b0:c4:42:98:fc:1c:14:9a:fb:f4:c8:99:6f:b9:24:\ +27:ae:41:e4:64:9b:93:4c:a4:95:99:1b:78:52:b8:55"; + + #[test] + fn valid_fingerprint_is_accepted() { + assert_eq!(VALID.len(), 95, "test constant must be 95 chars"); + assert!(is_valid_cert_fingerprint(VALID)); + } + + #[test] + fn uppercase_hex_is_accepted() { + assert!(is_valid_cert_fingerprint(&VALID.to_uppercase())); + } + + #[test] + fn empty_fingerprint_is_rejected() { + assert!(!is_valid_cert_fingerprint("")); + } + + #[test] + fn wrong_length_is_rejected() { + // One pair short, and one pair too many. + assert!(!is_valid_cert_fingerprint(&VALID[..92])); + assert!(!is_valid_cert_fingerprint(&format!("{VALID}:00"))); + } + + #[test] + fn non_hex_characters_are_rejected() { + // 'z' is not a hex digit; length still 95. + let bad = VALID.replacen('e', "z", 1); + assert_eq!(bad.len(), 95); + assert!(!is_valid_cert_fingerprint(&bad)); + } + + #[test] + fn wrong_separator_is_rejected() { + // Dashes instead of colons — same length, same hex digits. + let bad = VALID.replace(':', "-"); + assert_eq!(bad.len(), 95); + assert!(!is_valid_cert_fingerprint(&bad)); + } + + #[test] + fn misplaced_separator_is_rejected() { + // Swap a colon with an adjacent hex digit so the colons land off-grid + // while the length and character set stay legal. + let mut bytes = VALID.as_bytes().to_vec(); + bytes.swap(2, 3); + let bad = String::from_utf8(bytes).unwrap(); + assert_eq!(bad.len(), 95); + assert!(!is_valid_cert_fingerprint(&bad)); + } + + #[test] + fn whitespace_padding_is_rejected() { + // A pasted fingerprint with surrounding whitespace must not slip + // through — it would be stored verbatim and never match a real cert. + assert!(!is_valid_cert_fingerprint(&format!(" {VALID}"))); + assert!(!is_valid_cert_fingerprint(&format!("{VALID} "))); + } + + #[test] + fn non_ascii_of_correct_byte_length_is_rejected() { + // Guards the byte-indexed validator against multi-byte input. + let bad = format!("é{}", &VALID[..93]); + assert!(!is_valid_cert_fingerprint(&bad)); + } +} diff --git a/Client/tauri-client/tests/unit/credentials.test.ts b/Client/tauri-client/tests/unit/credentials.test.ts new file mode 100644 index 00000000..18401bf9 --- /dev/null +++ b/Client/tauri-client/tests/unit/credentials.test.ts @@ -0,0 +1,180 @@ +/** + * Tests for src/lib/credentials.ts. + * + * This module was excluded from coverage in vitest.config.ts and only ever + * `vi.mock`ed by other suites, so none of it had ever been executed under test. + * It is the JS half of the OS keychain integration ("stay signed in"), and every + * function is written to fail soft — returning false/null rather than throwing — + * which is precisely why silent breakage here goes unnoticed. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const invoke = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invoke(...args) as unknown, +})); + +const { saveCredential, loadCredential, deleteCredential } = await import("@lib/credentials"); + +beforeEach(() => { + invoke.mockReset().mockResolvedValue(undefined); +}); + +// ── saveCredential ───────────────────────────────────────────────────────── + +describe("saveCredential", () => { + it("forwards host, username and token to the Rust command", async () => { + await expect(saveCredential("h.example", "alice", "tok")).resolves.toBe(true); + + expect(invoke).toHaveBeenCalledWith("save_credential", { + host: "h.example", + username: "alice", + token: "tok", + password: null, + }); + }); + + it("passes an explicit password through", async () => { + await saveCredential("h.example", "alice", "tok", "s3cret"); + + expect(invoke).toHaveBeenCalledWith("save_credential", { + host: "h.example", + username: "alice", + token: "tok", + password: "s3cret", + }); + }); + + it("normalises a missing password to null rather than undefined", async () => { + await saveCredential("h.example", "alice", "tok"); + + // undefined would be dropped from the IPC payload and the Rust side would + // see a missing argument instead of an explicit "no password". + const args = invoke.mock.calls[0]?.[1] as Record; + expect(args.password).toBeNull(); + expect("password" in args).toBe(true); + }); + + it("returns false when the command rejects", async () => { + invoke.mockRejectedValue(new Error("keychain locked")); + + await expect(saveCredential("h.example", "alice", "tok")).resolves.toBe(false); + }); +}); + +// ── loadCredential ───────────────────────────────────────────────────────── + +describe("loadCredential", () => { + it("returns the stored username and token", async () => { + invoke.mockResolvedValue({ username: "alice", token: "tok" }); + + await expect(loadCredential("h.example")).resolves.toEqual({ + username: "alice", + token: "tok", + }); + expect(invoke).toHaveBeenCalledWith("load_credential", { host: "h.example" }); + }); + + it("drops any extra fields the backend returns", async () => { + // The Rust side deliberately stopped returning the password over IPC; if it + // ever regresses, the password must not make it into the JS heap. + invoke.mockResolvedValue({ username: "alice", token: "tok", password: "leaked" }); + + const got = await loadCredential("h.example"); + + expect(got).toEqual({ username: "alice", token: "tok" }); + expect(got).not.toHaveProperty("password"); + }); + + it("returns null when nothing is stored", async () => { + invoke.mockResolvedValue(null); + + await expect(loadCredential("h.example")).resolves.toBeNull(); + }); + + it.each([ + ["a string", "not-an-object"], + ["a number", 42], + ["an object with no username", { token: "tok" }], + ["an object with no token", { username: "alice" }], + ["a non-string username", { username: 1, token: "tok" }], + ["a non-string token", { username: "alice", token: 1 }], + ])("returns null for a malformed result (%s)", async (_label, result) => { + invoke.mockResolvedValue(result); + + await expect(loadCredential("h.example")).resolves.toBeNull(); + }); + + it("returns null when the command rejects", async () => { + invoke.mockRejectedValue(new Error("keychain locked")); + + await expect(loadCredential("h.example")).resolves.toBeNull(); + }); +}); + +// ── deleteCredential ─────────────────────────────────────────────────────── + +describe("deleteCredential", () => { + it("forwards the host and reports success", async () => { + await expect(deleteCredential("h.example")).resolves.toBe(true); + + expect(invoke).toHaveBeenCalledWith("delete_credential", { host: "h.example" }); + }); + + it("returns false when the command rejects", async () => { + invoke.mockRejectedValue(new Error("no such entry")); + + await expect(deleteCredential("h.example")).resolves.toBe(false); + }); +}); + +// ── non-Tauri fallback ───────────────────────────────────────────────────── + +describe("outside Tauri", () => { + /** + * Re-imports credentials.ts with the Tauri core module supplying no `invoke` + * export, so getInvoke() resolves to a falsy value and every function takes + * its "Tauri not available" early return. That is the same branch a genuine + * import failure lands on (a plain browser, or a test that never stubs the + * module), reached deterministically. + * + * The branch matters because the connect screen calls loadCredential + * unconditionally on mount: it must return null, not reject. + */ + async function importWithoutInvoke(): Promise { + vi.resetModules(); + vi.doMock("@tauri-apps/api/core", () => ({})); + return import("@lib/credentials"); + } + + afterEach(() => { + // credentials.ts imports the Tauri core module lazily, inside getInvoke — + // so the mock has to stay in place until after the call under test, not + // just until the module import. + vi.doUnmock("@tauri-apps/api/core"); + vi.resetModules(); + }); + + it("saveCredential reports failure instead of throwing", async () => { + const { saveCredential: save } = await importWithoutInvoke(); + + await expect(save("h.example", "alice", "tok")).resolves.toBe(false); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("loadCredential returns null instead of throwing", async () => { + const { loadCredential: load } = await importWithoutInvoke(); + + await expect(load("h.example")).resolves.toBeNull(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("deleteCredential reports failure instead of throwing", async () => { + const { deleteCredential: del } = await importWithoutInvoke(); + + await expect(del("h.example")).resolves.toBe(false); + expect(invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/Client/tauri-client/tests/unit/deep-link-init.test.ts b/Client/tauri-client/tests/unit/deep-link-init.test.ts new file mode 100644 index 00000000..0db1d2b4 --- /dev/null +++ b/Client/tauri-client/tests/unit/deep-link-init.test.ts @@ -0,0 +1,166 @@ +/** + * Tests for initDeepLinks in src/lib/deep-link.ts. + * + * deep-link.ts sat at 44% statements: the existing deep-link.test.ts covers the + * pure parser, but initDeepLinks — the part that actually runs at startup and + * decides whether an owncord:// invite reaches the register form — had no + * coverage at all. Its failure modes are all silent by design (every step is + * wrapped in try/catch so a missing plugin cannot break boot), which is exactly + * why the branches need pinning: a regression here does not throw, it just + * quietly stops honouring invite links. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const register = vi.fn(); +const getCurrent = vi.fn(); +const onOpenUrl = vi.fn(); + +vi.mock("@tauri-apps/plugin-deep-link", () => ({ + register: (...args: unknown[]) => register(...args) as unknown, + getCurrent: (...args: unknown[]) => getCurrent(...args) as unknown, + onOpenUrl: (...args: unknown[]) => onOpenUrl(...args) as unknown, +})); + +const { initDeepLinks, parseInviteLink } = await import("@lib/deep-link"); + +beforeEach(() => { + register.mockReset().mockResolvedValue(undefined); + getCurrent.mockReset().mockResolvedValue(null); + onOpenUrl.mockReset().mockResolvedValue(undefined); +}); + +describe("initDeepLinks", () => { + it("registers the owncord scheme", async () => { + await initDeepLinks(vi.fn()); + + expect(register).toHaveBeenCalledWith("owncord"); + }); + + it("continues when scheme registration is rejected", async () => { + // Registration fails when the scheme is already claimed, or on platforms + // that do not permit runtime registration. Neither must stop the listener + // from being wired. + register.mockRejectedValue(new Error("already registered")); + + await initDeepLinks(vi.fn()); + + expect(onOpenUrl).toHaveBeenCalled(); + }); + + it("dispatches a cold-start invite from getCurrent", async () => { + getCurrent.mockResolvedValue(["owncord://invite/COLD1"]); + const onInvite = vi.fn(); + + await initDeepLinks(onInvite); + + expect(onInvite).toHaveBeenCalledWith("COLD1", undefined); + }); + + it("passes the host through when the link carries one", async () => { + getCurrent.mockResolvedValue(["owncord://invite/COLD2?host=chat.example.com:8443"]); + const onInvite = vi.fn(); + + await initDeepLinks(onInvite); + + expect(onInvite).toHaveBeenCalledWith("COLD2", "chat.example.com:8443"); + }); + + it("tolerates a null cold-start result", async () => { + getCurrent.mockResolvedValue(null); + const onInvite = vi.fn(); + + await initDeepLinks(onInvite); + + expect(onInvite).not.toHaveBeenCalled(); + expect(onOpenUrl).toHaveBeenCalled(); + }); + + it("dispatches every link in a batch", async () => { + getCurrent.mockResolvedValue(["owncord://invite/A", "owncord://invite/B"]); + const onInvite = vi.fn(); + + await initDeepLinks(onInvite); + + expect(onInvite).toHaveBeenCalledTimes(2); + expect(onInvite).toHaveBeenNthCalledWith(1, "A", undefined); + expect(onInvite).toHaveBeenNthCalledWith(2, "B", undefined); + }); + + it("ignores unrecognized links but still handles valid ones in the batch", async () => { + getCurrent.mockResolvedValue([ + "https://example.com/invite/NOPE", + "owncord://", + "owncord://invite/GOOD", + ]); + const onInvite = vi.fn(); + + await initDeepLinks(onInvite); + + expect(onInvite).toHaveBeenCalledTimes(1); + expect(onInvite).toHaveBeenCalledWith("GOOD", undefined); + }); + + it("dispatches warm-launch invites through the onOpenUrl callback", async () => { + const onInvite = vi.fn(); + await initDeepLinks(onInvite); + + // The plugin hands the app a batch of URLs while it is already running. + const handler = onOpenUrl.mock.calls[0]?.[0] as (urls: readonly string[]) => void; + expect(handler).toBeTypeOf("function"); + handler(["owncord://invite/WARM?host=h.example"]); + + expect(onInvite).toHaveBeenCalledWith("WARM", "h.example"); + }); + + it("swallows a getCurrent rejection without wiring a listener", async () => { + getCurrent.mockRejectedValue(new Error("ipc down")); + const onInvite = vi.fn(); + + await expect(initDeepLinks(onInvite)).resolves.toBeUndefined(); + + expect(onInvite).not.toHaveBeenCalled(); + expect(onOpenUrl).not.toHaveBeenCalled(); + }); + + it("swallows an onOpenUrl rejection", async () => { + onOpenUrl.mockRejectedValue(new Error("no listener slot")); + + await expect(initDeepLinks(vi.fn())).resolves.toBeUndefined(); + }); +}); + +describe("parseInviteLink malformed percent-encoding", () => { + it("falls back to the raw segment when decoding throws", () => { + // A lone "%" is not valid percent-encoding; decodeURIComponent throws a + // URIError and the raw segment is used instead of losing the invite. + expect(parseInviteLink("owncord://invite/100%")).toEqual({ code: "100%" }); + }); + + it("drops a code that decodes to whitespace only", () => { + expect(parseInviteLink("owncord://invite/%20%20")).toBeNull(); + }); + + it("ignores an empty host parameter", () => { + expect(parseInviteLink("owncord://invite/ABC?host=")).toEqual({ code: "ABC" }); + }); + + it("trims a padded host parameter", () => { + expect(parseInviteLink("owncord://invite/ABC?host=%20h.example%20")).toEqual({ + code: "ABC", + host: "h.example", + }); + }); + + it("ignores unrelated query parameters", () => { + expect(parseInviteLink("owncord://invite/ABC?ref=twitter")).toEqual({ code: "ABC" }); + }); + + it("tolerates multiple trailing slashes", () => { + expect(parseInviteLink("owncord://invite/ABC///")).toEqual({ code: "ABC" }); + }); + + it("ignores extra path segments after the code", () => { + expect(parseInviteLink("owncord://invite/ABC/extra")).toEqual({ code: "ABC" }); + }); +}); diff --git a/Client/tauri-client/tests/unit/drag-reorder.test.ts b/Client/tauri-client/tests/unit/drag-reorder.test.ts new file mode 100644 index 00000000..6a5c090d --- /dev/null +++ b/Client/tauri-client/tests/unit/drag-reorder.test.ts @@ -0,0 +1,486 @@ +/** + * Tests for src/components/channel-sidebar/drag-reorder.ts. + * + * This module was the least-covered file in the client (38.8% statements) and + * had no test of its own — the reorder index arithmetic, the admin-only gate, + * the 5px drag threshold and the listener ref-counting were all unverified. + * Off-by-one errors in the insert index silently reorder the wrong pair of + * channels for every member of the server. + */ + +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; + +import { + attachDragHandlers, + ensureGlobalDragListeners, + releaseGlobalDragListeners, +} from "@components/channel-sidebar/drag-reorder"; +import type { ChannelReorderData } from "@components/ChannelSidebar"; +import { authStore } from "@stores/auth.store"; +import { channelsStore } from "@stores/channels.store"; +import type { Channel } from "@stores/channels.store"; +import type { UserWithRole } from "@lib/types"; + +// ── helpers ──────────────────────────────────────────────────────────────── + +function makeUser(role: string): UserWithRole { + return { id: 1, username: "tester", avatar: null, role }; +} + +function signIn(role: string): void { + authStore.setState(() => ({ + token: "t", + user: makeUser(role), + serverName: "s", + motd: "", + isAuthenticated: true, + })); +} + +function makeCh(id: number, position: number, name = `ch-${id}`): Channel { + return { + id, + name, + type: "text", + category: null, + position, + unreadCount: 0, + lastMessageId: null, + canSend: true, + }; +} + +interface Rig { + container: HTMLElement; + items: Map; + channels: Channel[]; + onReorder: ReturnType; + abort: AbortController; +} + +/** Builds a container with one 20px-tall row per channel, stacked vertically. */ +function buildRig(channels: Channel[]): Rig { + const container = document.createElement("div"); + document.body.appendChild(container); + + const items = new Map(); + const onReorder = vi.fn(); + const abort = new AbortController(); + + channels.forEach((ch, idx) => { + const el = document.createElement("div"); + container.appendChild(el); + // jsdom does not lay out, so stub the geometry the module reads. + const top = idx * 20; + el.getBoundingClientRect = () => + ({ + top, + bottom: top + 20, + height: 20, + left: 0, + right: 100, + width: 100, + x: 0, + y: top, + toJSON: () => ({}), + }) as DOMRect; + attachDragHandlers(el, ch, container, channels, abort.signal, onReorder); + items.set(ch.id, el); + }); + + return { container, items, channels, onReorder, abort }; +} + +/** Row `idx` spans y = idx*20 .. idx*20+20; its midpoint is +10. */ +function yInRow(idx: number, half: "top" | "bottom"): number { + return idx * 20 + (half === "top" ? 4 : 16); +} + +function mouse(type: string, clientX: number, clientY: number): MouseEvent { + return new MouseEvent(type, { clientX, clientY, button: 0, bubbles: true }); +} + +/** Drives a full drag of `fromId` onto the given half of row `toIdx`. */ +function drag(rig: Rig, fromId: number, toIdx: number, half: "top" | "bottom"): void { + const source = rig.items.get(fromId); + if (source === undefined) throw new Error(`no row for channel ${fromId}`); + const fromIdx = rig.channels.findIndex((c) => c.id === fromId); + + source.dispatchEvent(mouse("mousedown", 0, yInRow(fromIdx, "top"))); + // Past the 5px threshold so the drag activates. + source.dispatchEvent(mouse("mousemove", 0, yInRow(fromIdx, "top") + 20)); + document.dispatchEvent(mouse("mouseup", 0, yInRow(toIdx, half))); +} + +beforeEach(() => { + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); + channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] })); + document.body.className = ""; + document.body.innerHTML = ""; +}); + +afterEach(() => { + // End any drag still in flight. `activeDrag` is module-level state and + // releaseGlobalDragListeners() only clears it when handed the owning + // container, so without this a half-finished drag leaks into the next test + // and blocks it from starting one. Re-arm the global handlers first: a test + // that drained the ref-count has aborted them, and the mouseup below needs a + // live listener to do the clearing. + ensureGlobalDragListeners(); + document.dispatchEvent(mouse("mouseup", 0, -1000)); + // Drain the ref-count so the document listeners do not leak between tests. + for (let i = 0; i < 50; i++) releaseGlobalDragListeners(); + document.body.innerHTML = ""; + document.body.className = ""; +}); + +// ── permission gate ──────────────────────────────────────────────────────── + +describe("attachDragHandlers permission gate", () => { + it.each([ + ["owner", true], + ["admin", true], + ["Owner", true], + ["ADMIN", true], + ["moderator", false], + ["member", false], + ["", false], + ])("role %s → draggable %s", (role, draggable) => { + signIn(role); + const rig = buildRig([makeCh(1, 0)]); + const el = rig.items.get(1); + + expect(el?.classList.contains("channel-draggable")).toBe(draggable); + }); + + it("does nothing when no user is signed in", () => { + const rig = buildRig([makeCh(1, 0)]); + + expect(rig.items.get(1)?.classList.contains("channel-draggable")).toBe(false); + }); + + it("does nothing when no onReorderChannel callback is supplied", () => { + signIn("owner"); + const container = document.createElement("div"); + document.body.appendChild(container); + const el = document.createElement("div"); + container.appendChild(el); + + attachDragHandlers(el, makeCh(1, 0), container, [makeCh(1, 0)], new AbortController().signal); + + expect(el.classList.contains("channel-draggable")).toBe(false); + expect(el.dataset.dragChannelId).toBeUndefined(); + }); + + it("stamps the channel id for hit-testing when permitted", () => { + signIn("admin"); + const rig = buildRig([makeCh(7, 0)]); + + expect(rig.items.get(7)?.dataset.dragChannelId).toBe("7"); + }); +}); + +// ── drag threshold ───────────────────────────────────────────────────────── + +describe("drag activation threshold", () => { + it("does not start a drag below the 5px threshold", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + const el = rig.items.get(1); + + el?.dispatchEvent(mouse("mousedown", 0, 4)); + el?.dispatchEvent(mouse("mousemove", 2, 6)); // dx+dy = 4 + + expect(el?.classList.contains("dragging")).toBe(false); + expect(document.body.classList.contains("channel-reordering")).toBe(false); + }); + + it("starts a drag once movement exceeds the threshold", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + const el = rig.items.get(1); + + el?.dispatchEvent(mouse("mousedown", 0, 4)); + el?.dispatchEvent(mouse("mousemove", 3, 10)); // dx+dy = 9 + + expect(el?.classList.contains("dragging")).toBe(true); + expect(document.body.classList.contains("channel-reordering")).toBe(true); + }); + + it("ignores non-left mouse buttons", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + const el = rig.items.get(1); + + el?.dispatchEvent(new MouseEvent("mousedown", { clientX: 0, clientY: 4, button: 2 })); + el?.dispatchEvent(mouse("mousemove", 0, 40)); + + expect(el?.classList.contains("dragging")).toBe(false); + }); + + it("a mouseup before the threshold cancels the pending drag", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + const el = rig.items.get(1); + + el?.dispatchEvent(mouse("mousedown", 0, 4)); + el?.dispatchEvent(mouse("mouseup", 0, 4)); + el?.dispatchEvent(mouse("mousemove", 0, 60)); + + expect(el?.classList.contains("dragging")).toBe(false); + }); +}); + +// ── reorder arithmetic ───────────────────────────────────────────────────── + +describe("reorder index arithmetic", () => { + it("dropping on the top half inserts before the target", () => { + signIn("owner"); + // Rows: idx0=ch1, idx1=ch2, idx2=ch3. + const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]); + + drag(rig, 3, 0, "top"); // ch3 before ch1 → [3, 1, 2] + + expect(rig.onReorder).toHaveBeenCalledTimes(1); + const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[]; + expect(positionsOf(reorders)).toEqual({ 3: 0, 1: 1, 2: 2 }); + }); + + it("dropping on the bottom half inserts after the target", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]); + + drag(rig, 1, 1, "bottom"); // ch1 after ch2 → [2, 1, 3] + + const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[]; + expect(positionsOf(reorders)).toEqual({ 2: 0, 1: 1 }); + }); + + it("only reports channels whose position actually changed", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2), makeCh(4, 3)]); + + drag(rig, 1, 1, "bottom"); // → [2, 1, 3, 4]; ch3 and ch4 keep their slots + + const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[]; + const touched = reorders.map((r) => r.channelId).sort((a, b) => a - b); + expect(touched).toEqual([1, 2]); + }); + + it("updates the channels store immediately (optimistic reorder)", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + channelsStore.setState(() => ({ + channels: new Map([ + [1, makeCh(1, 0)], + [2, makeCh(2, 1)], + ]), + activeChannelId: null, + roles: [], + })); + + drag(rig, 1, 1, "bottom"); // → [2, 1] + + expect(channelsStore.select((s) => s.channels.get(1)?.position)).toBe(1); + expect(channelsStore.select((s) => s.channels.get(2)?.position)).toBe(0); + }); + + it("does not fire when dropped on itself", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + + drag(rig, 1, 0, "bottom"); // row 0 is ch1 itself + + expect(rig.onReorder).not.toHaveBeenCalled(); + }); + + it("does not fire when dropped outside any row", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + const el = rig.items.get(1); + + el?.dispatchEvent(mouse("mousedown", 0, 4)); + el?.dispatchEvent(mouse("mousemove", 0, 30)); + document.dispatchEvent(mouse("mouseup", 0, 9999)); // below every row + + expect(rig.onReorder).not.toHaveBeenCalled(); + }); + + it("does not fire when the drag lands back in its original slot", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]); + + // ch1 dropped on the top half of ch2 → insert before ch2 → [1, 2, 3], + // which is the order it already had. + drag(rig, 1, 1, "top"); + + expect(rig.onReorder).not.toHaveBeenCalled(); + }); + + it("clears visual state on drop", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + + drag(rig, 1, 1, "bottom"); + + expect(rig.items.get(1)?.classList.contains("dragging")).toBe(false); + expect(document.body.classList.contains("channel-reordering")).toBe(false); + expect(rig.container.querySelectorAll(".channel-drop-indicator")).toHaveLength(0); + }); +}); + +// ── drop indicator ───────────────────────────────────────────────────────── + +describe("drop indicator", () => { + it("marks the hovered row and never the dragged row", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]); + const source = rig.items.get(1); + + source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top"))); + source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20)); + + document.dispatchEvent(mouse("mousemove", 0, yInRow(2, "top"))); + expect(rig.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(true); + + // Hovering the dragged row itself must not show a drop target. + document.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top"))); + expect(rig.items.get(1)?.classList.contains("channel-drop-indicator")).toBe(false); + expect(rig.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(false); + }); + + it("moves the indicator as the cursor moves between rows", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]); + const source = rig.items.get(1); + + source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top"))); + source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20)); + + document.dispatchEvent(mouse("mousemove", 0, yInRow(1, "top"))); + expect(rig.items.get(2)?.classList.contains("channel-drop-indicator")).toBe(true); + + document.dispatchEvent(mouse("mousemove", 0, yInRow(2, "top"))); + expect(rig.items.get(2)?.classList.contains("channel-drop-indicator")).toBe(false); + expect(rig.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(true); + }); + + it("global handlers no-op when no drag is active", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + + document.dispatchEvent(mouse("mousemove", 0, yInRow(1, "top"))); + document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "top"))); + + expect(rig.container.querySelectorAll(".channel-drop-indicator")).toHaveLength(0); + expect(rig.onReorder).not.toHaveBeenCalled(); + }); +}); + +// ── listener lifecycle ───────────────────────────────────────────────────── + +describe("global listener ref-counting", () => { + it("keeps listeners alive until every ref is released", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); // 2 channels → 2 refs + ensureGlobalDragListeners(); // a second sidebar → 3 + + releaseGlobalDragListeners(); // → 2 + + // Refs still outstanding, so a drag must still work. + drag(rig, 1, 1, "bottom"); + expect(rig.onReorder).toHaveBeenCalledTimes(1); + }); + + /** + * KNOWN BUG — the ref-count is asymmetric. + * + * `attachDragHandlers` calls `ensureGlobalDragListeners()` once per *channel + * element* (ChannelSidebar.ts:431, inside the per-channel render), but + * `releaseGlobalDragListeners` is called once per *sidebar destroy* + * (ChannelSidebar.ts:703). So a sidebar showing N channels takes N refs and + * gives back 1, and every re-render takes N more. In production the count + * never returns to 0, the AbortController never fires, and the two document + * listeners (plus the `activeDrag` closure they capture) live for the rest + * of the process. + * + * The leak is currently benign — both handlers return immediately while + * `activeDrag` is null, and re-registration is guarded — so this test pins + * the behaviour as it actually is rather than as the doc comment describes + * it ("only the last destroy tears them down"). If the ref-counting is + * fixed so one release per sidebar suffices, this test should change with it. + */ + it("takes one ref per channel, so a single release does not tear down", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); // 2 refs, not 1 + + releaseGlobalDragListeners(); // → 1, not 0 + + drag(rig, 1, 1, "bottom"); + expect(rig.onReorder).toHaveBeenCalledTimes(1); + }); + + it("tears listeners down once the ref-count reaches zero", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + + releaseGlobalDragListeners(); + releaseGlobalDragListeners(); // drops to 0 → AbortController fires + + drag(rig, 1, 1, "bottom"); + expect(rig.onReorder).not.toHaveBeenCalled(); + }); + + it("clears an in-flight drag owned by the released container", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + const source = rig.items.get(1); + + source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top"))); + source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20)); + expect(source?.classList.contains("dragging")).toBe(true); + + releaseGlobalDragListeners(rig.container); + + // A sidebar destroyed mid-drag must not leave the row stuck in the + // dragging state or the body stuck in reorder mode. + expect(source?.classList.contains("dragging")).toBe(false); + expect(document.body.classList.contains("channel-reordering")).toBe(false); + }); + + it("leaves a drag owned by a different container alone", () => { + signIn("owner"); + const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]); + const source = rig.items.get(1); + const otherContainer = document.createElement("div"); + + source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top"))); + source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20)); + + ensureGlobalDragListeners(); // keep the count above zero + releaseGlobalDragListeners(otherContainer); + + expect(source?.classList.contains("dragging")).toBe(true); + }); + + it("release is safe to over-call", () => { + expect(() => { + releaseGlobalDragListeners(); + releaseGlobalDragListeners(); + releaseGlobalDragListeners(); + }).not.toThrow(); + }); +}); + +/** Collapses reorder data into a channelId → newPosition map. */ +function positionsOf(reorders: readonly ChannelReorderData[]): Record { + const out: Record = {}; + for (const r of reorders) out[r.channelId] = r.newPosition; + return out; +} diff --git a/Client/tauri-client/tests/unit/livekit-diagnostics.test.ts b/Client/tauri-client/tests/unit/livekit-diagnostics.test.ts new file mode 100644 index 00000000..78e084a7 --- /dev/null +++ b/Client/tauri-client/tests/unit/livekit-diagnostics.test.ts @@ -0,0 +1,419 @@ +/** + * Tests for src/lib/livekitDiagnostics.ts (was 30.4% statements, no test file). + * + * This module is what an operator reads when a voice call fails to connect, so + * a diagnostic that silently returns nothing — or worse, throws while poking at + * LiveKit's private `engine` field — makes a hard bug harder. Every function + * here reaches through `room as unknown as Record` into + * internals that can disappear on a LiveKit upgrade, so the defensive paths + * matter more than the happy path. + */ + +import { describe, expect, it, vi } from "vitest"; +import { RoomEvent } from "livekit-client"; +import type { Room } from "livekit-client"; + +import { + attachDiagnosticListeners, + buildSessionDebugInfo, + getIceConnectionState, + logIceConnectionInfo, +} from "@lib/livekitDiagnostics"; +import type { SessionDebugDeps } from "@lib/livekitDiagnostics"; +import type { AudioPipeline } from "@lib/audioPipeline"; +import type { AudioElements } from "@lib/audioElements"; + +// ── fakes ────────────────────────────────────────────────────────────────── + +/** A Room stub that records `on` registrations so they can be fired by name. */ +function fakeRoom(extra: Record = {}): { + room: Room; + handlers: Map void>; +} { + const handlers = new Map void>(); + const room = { + on(event: string, cb: (...args: unknown[]) => void) { + handlers.set(event, cb); + return this; + }, + ...extra, + } as unknown as Room; + return { room, handlers }; +} + +function fakePeerConnection(over: Partial = {}): RTCPeerConnection { + return { + iceConnectionState: "connected", + iceGatheringState: "complete", + connectionState: "connected", + signalingState: "stable", + getStats: vi.fn().mockResolvedValue(new Map()), + ...over, + } as unknown as RTCPeerConnection; +} + +/** Builds an engine object shaped the way the module expects to find it. */ +function withEngine(subscriberPc?: RTCPeerConnection, publisherPc?: RTCPeerConnection): Room { + return { + engine: { + ...(subscriberPc !== undefined ? { subscriber: { pc: subscriberPc } } : {}), + ...(publisherPc !== undefined ? { publisher: { pc: publisherPc } } : {}), + }, + } as unknown as Room; +} + +function fakePipeline(over: Partial = {}): AudioPipeline { + return { + isActive: true, + gainValue: 1, + ctxState: "running", + isVadGated: false, + inputGain: 1, + ...over, + } as unknown as AudioPipeline; +} + +function fakeElements(effectiveVolume = 1): AudioElements { + return { getEffectiveVolume: () => effectiveVolume } as unknown as AudioElements; +} + +// ── attachDiagnosticListeners ────────────────────────────────────────────── + +describe("attachDiagnosticListeners", () => { + it("registers every diagnostic room event", () => { + const { room, handlers } = fakeRoom(); + + attachDiagnosticListeners(room); + + for (const event of [ + RoomEvent.Reconnecting, + RoomEvent.Reconnected, + RoomEvent.SignalReconnecting, + RoomEvent.MediaDevicesError, + RoomEvent.ConnectionQualityChanged, + ]) { + expect(handlers.has(event), `missing handler for ${event}`).toBe(true); + } + }); + + it("handlers run without throwing", () => { + const { room, handlers } = fakeRoom(); + attachDiagnosticListeners(room); + + expect(() => { + handlers.get(RoomEvent.Reconnecting)?.(); + handlers.get(RoomEvent.Reconnected)?.(); + handlers.get(RoomEvent.SignalReconnecting)?.(); + handlers.get(RoomEvent.MediaDevicesError)?.(new Error("no mic")); + handlers.get(RoomEvent.ConnectionQualityChanged)?.("excellent", { isLocal: true }); + handlers.get(RoomEvent.ConnectionQualityChanged)?.("poor", { isLocal: false }); + }).not.toThrow(); + }); +}); + +// ── logIceConnectionInfo ─────────────────────────────────────────────────── + +describe("logIceConnectionInfo", () => { + it("is a no-op for a null room", () => { + expect(() => { + logIceConnectionInfo(null); + }).not.toThrow(); + }); + + it("is a no-op when the engine is absent", () => { + expect(() => { + logIceConnectionInfo({} as unknown as Room); + }).not.toThrow(); + }); + + it("is a no-op when the engine has neither peer connection", () => { + expect(() => { + logIceConnectionInfo({ engine: {} } as unknown as Room); + }).not.toThrow(); + }); + + it("reads stats from both peer connections", () => { + const sub = fakePeerConnection(); + const pub = fakePeerConnection(); + + logIceConnectionInfo(withEngine(sub, pub)); + + expect(sub.getStats).toHaveBeenCalled(); + expect(pub.getStats).toHaveBeenCalled(); + }); + + it("resolves the selected candidate pair from the stats report", async () => { + const stats = new Map>([ + [ + "pair1", + { + id: "pair1", + type: "candidate-pair", + state: "succeeded", + localCandidateId: "lc1", + remoteCandidateId: "rc1", + }, + ], + ["lc1", { id: "lc1", type: "local-candidate", candidateType: "srflx", protocol: "udp" }], + ["rc1", { id: "rc1", type: "remote-candidate", candidateType: "relay" }], + ]); + const getStats = vi.fn().mockResolvedValue(stats); + + logIceConnectionInfo(withEngine(fakePeerConnection({ getStats } as never))); + await vi.waitFor(() => { + expect(getStats).toHaveBeenCalled(); + }); + }); + + it("tolerates candidate ids that resolve to nothing", async () => { + // A pair referencing candidates absent from the report leaves the types as + // "unknown" rather than throwing. + const stats = new Map>([ + [ + "pair1", + { + id: "pair1", + type: "candidate-pair", + state: "succeeded", + localCandidateId: "missing", + remoteCandidateId: "also-missing", + }, + ], + ]); + const getStats = vi.fn().mockResolvedValue(stats); + + expect(() => { + logIceConnectionInfo(withEngine(fakePeerConnection({ getStats } as never))); + }).not.toThrow(); + await vi.waitFor(() => { + expect(getStats).toHaveBeenCalled(); + }); + }); + + it("swallows a rejected getStats", async () => { + const getStats = vi.fn().mockRejectedValue(new Error("pc closed")); + + expect(() => { + logIceConnectionInfo(withEngine(fakePeerConnection({ getStats } as never))); + }).not.toThrow(); + await vi.waitFor(() => { + expect(getStats).toHaveBeenCalled(); + }); + }); + + it("swallows a throwing engine getter", () => { + const room = { + get engine(): unknown { + throw new Error("internals moved"); + }, + } as unknown as Room; + + // The whole point of the try/catch: a LiveKit upgrade that renames or + // guards `engine` must not take the voice session down with it. + expect(() => { + logIceConnectionInfo(room); + }).not.toThrow(); + }); +}); + +// ── getIceConnectionState ────────────────────────────────────────────────── + +describe("getIceConnectionState", () => { + it("returns null for a null room", () => { + expect(getIceConnectionState(null)).toBeNull(); + }); + + it("returns null when the engine is absent", () => { + expect(getIceConnectionState({} as unknown as Room)).toBeNull(); + }); + + it("returns an empty object when the engine has no peer connections", () => { + expect(getIceConnectionState({ engine: {} } as unknown as Room)).toEqual({}); + }); + + it("reports both peer connections", () => { + const got = getIceConnectionState( + withEngine( + fakePeerConnection({ iceConnectionState: "checking", connectionState: "connecting" }), + fakePeerConnection({ iceConnectionState: "connected", connectionState: "connected" }), + ), + ); + + expect(got).toEqual({ + subscriber: { iceConnectionState: "checking", connectionState: "connecting" }, + publisher: { iceConnectionState: "connected", connectionState: "connected" }, + }); + }); + + it("reports only the peer connection that exists", () => { + const got = getIceConnectionState(withEngine(fakePeerConnection())); + + expect(got).toHaveProperty("subscriber"); + expect(got).not.toHaveProperty("publisher"); + }); + + it("returns null when reaching into internals throws", () => { + const room = { + get engine(): unknown { + throw new Error("internals moved"); + }, + } as unknown as Room; + + expect(getIceConnectionState(room)).toBeNull(); + }); +}); + +// ── buildSessionDebugInfo ────────────────────────────────────────────────── + +describe("buildSessionDebugInfo", () => { + const baseDeps: Omit = { + currentChannelId: 12, + outputVolumeMultiplier: 1.5, + audioPipeline: fakePipeline(), + audioElements: fakeElements(), + }; + + it("returns the minimal shape when there is no room", () => { + const got = buildSessionDebugInfo({ ...baseDeps, room: null }); + + expect(got).toEqual({ hasRoom: false, hasRNNoiseProcessor: false, currentChannelId: 12 }); + }); + + it("summarises an active room", () => { + const room = { + name: "channel-12", + state: "connected", + remoteParticipants: new Map(), + localParticipant: { + identity: "user-1:tok", + trackPublications: new Map(), + getTrackPublication: () => undefined, + }, + engine: {}, + } as unknown as Room; + + const got = buildSessionDebugInfo({ ...baseDeps, room }); + + expect(got.hasRoom).toBe(true); + expect(got.roomName).toBe("channel-12"); + expect(got.roomState).toBe("connected"); + expect(got.currentChannelId).toBe(12); + expect(got.outputVolumeMultiplier).toBe(1.5); + expect(got.localParticipant).toBe("user-1:tok"); + expect(got.audioPipelineActive).toBe(true); + expect(got.audioPipelineGain).toBe(1); + expect(got.audioPipelineCtxState).toBe("running"); + expect(got.vadGated).toBe(false); + expect(got.currentInputGain).toBe(1); + expect(got.hasRNNoiseProcessor).toBe(false); + }); + + it("reports hasRNNoiseProcessor when the mic track carries a processor", () => { + const room = { + name: "r", + state: "connected", + remoteParticipants: new Map(), + localParticipant: { + identity: "user-1", + trackPublications: new Map(), + getTrackPublication: () => ({ track: { getProcessor: () => ({ name: "rnnoise" }) } }), + }, + engine: {}, + } as unknown as Room; + + expect(buildSessionDebugInfo({ ...baseDeps, room }).hasRNNoiseProcessor).toBe(true); + }); + + it("maps remote participants, their volumes and their tracks", () => { + const room = { + name: "r", + state: "connected", + remoteParticipants: new Map([ + [ + "user-7:tok", + { + identity: "user-7:tok", + getVolume: () => 0.8, + trackPublications: new Map([ + [ + "sid1", + { + trackSid: "sid1", + source: "microphone", + kind: "audio", + isSubscribed: true, + isEnabled: true, + }, + ], + ]), + }, + ], + ]), + localParticipant: { + identity: "user-1", + trackPublications: new Map(), + getTrackPublication: () => undefined, + }, + engine: {}, + } as unknown as Room; + + const got = buildSessionDebugInfo({ ...baseDeps, room, audioElements: fakeElements(0.6) }); + const remotes = got.remoteParticipants as Array>; + + expect(remotes).toHaveLength(1); + // The identity must be decoded to a numeric user id — the debug panel keys + // per-user volume off it. + expect(remotes[0]?.userId).toBe(7); + expect(remotes[0]?.volume).toBe(0.8); + expect(remotes[0]?.effectiveVolume).toBe(0.6); + expect(remotes[0]?.tracks).toEqual([ + { sid: "sid1", source: "microphone", kind: "audio", subscribed: true, enabled: true }, + ]); + }); + + it("maps local track publications", () => { + const room = { + name: "r", + state: "connected", + remoteParticipants: new Map(), + localParticipant: { + identity: "user-1", + trackPublications: new Map([ + ["a", { trackSid: "a", source: "microphone", kind: "audio", isMuted: false }], + ["b", { trackSid: "b", source: "screen_share", kind: "video", isMuted: true }], + ]), + getTrackPublication: () => undefined, + }, + engine: {}, + } as unknown as Room; + + expect(buildSessionDebugInfo({ ...baseDeps, room }).localTracks).toEqual([ + { sid: "a", source: "microphone", kind: "audio", isMuted: false }, + { sid: "b", source: "screen_share", kind: "video", isMuted: true }, + ]); + }); + + it("embeds the ICE state", () => { + const room = { + name: "r", + state: "connected", + remoteParticipants: new Map(), + localParticipant: { + identity: "user-1", + trackPublications: new Map(), + getTrackPublication: () => undefined, + }, + engine: { subscriber: { pc: fakePeerConnection({ iceConnectionState: "failed" }) } }, + } as unknown as Room; + + expect(buildSessionDebugInfo({ ...baseDeps, room }).iceConnectionState).toEqual({ + subscriber: { iceConnectionState: "failed", connectionState: "connected" }, + }); + }); + + it("carries a null channel id through", () => { + const got = buildSessionDebugInfo({ ...baseDeps, room: null, currentChannelId: null }); + + expect(got.currentChannelId).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/room-event-handlers.test.ts b/Client/tauri-client/tests/unit/room-event-handlers.test.ts new file mode 100644 index 00000000..477a57d8 --- /dev/null +++ b/Client/tauri-client/tests/unit/room-event-handlers.test.ts @@ -0,0 +1,623 @@ +/** + * Tests for src/lib/roomEventHandlers.ts (was 57.1% statements / 62.5% + * functions, no test file). + * + * These handlers are the whole reaction surface of a live voice call: audio and + * video attach/detach, speaker highlighting, autoplay unlocking, and the + * disconnect path that decides between "reconnect silently" and "drop the user + * out of voice". The disconnect branch matters most — getting it wrong either + * strands the user in a dead call or tears down a call that was only blipping. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DisconnectReason, Track } from "livekit-client"; +import type { + LocalTrackPublication, + Participant, + RemoteParticipant, + RemoteTrack, + RemoteTrackPublication, + Room, +} from "livekit-client"; + +import { createRoomEventHandlers } from "@lib/roomEventHandlers"; +import type { RoomEventDeps } from "@lib/roomEventHandlers"; +import { voiceStore } from "@stores/voice.store"; +import type { VoiceUser } from "@stores/voice.store"; +import type { AudioElements } from "@lib/audioElements"; + +// ── fakes ────────────────────────────────────────────────────────────────── + +function fakeAudioElements(): AudioElements & { + handleTrackSubscribedAudio: ReturnType; + handleTrackUnsubscribedAudio: ReturnType; + cleanupAllAudioElements: ReturnType; +} { + return { + handleTrackSubscribedAudio: vi.fn(), + handleTrackUnsubscribedAudio: vi.fn(), + cleanupAllAudioElements: vi.fn(), + getEffectiveVolume: vi.fn().mockReturnValue(1), + } as unknown as AudioElements & { + handleTrackSubscribedAudio: ReturnType; + handleTrackUnsubscribedAudio: ReturnType; + cleanupAllAudioElements: ReturnType; + }; +} + +interface Harness { + deps: RoomEventDeps; + handlers: ReturnType; + audioElements: ReturnType; + room: { + canPlaybackAudio: boolean; + startAudio: ReturnType; + removeAllListeners: ReturnType; + disconnect: ReturnType; + }; + spies: { + applyMicMuteState: ReturnType; + attemptAutoReconnect: ReturnType; + teardownForReconnect: ReturnType; + leaveVoice: ReturnType; + setRoom: ReturnType; + setReconnectAc: ReturnType; + syncModuleRooms: ReturnType; + onRemoteVideo: ReturnType; + onRemoteVideoRemoved: ReturnType; + onError: ReturnType; + }; +} + +function build(over: Partial = {}): Harness { + const audioElements = fakeAudioElements(); + const room = { + canPlaybackAudio: true, + startAudio: vi.fn().mockResolvedValue(undefined), + removeAllListeners: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined), + }; + const spies = { + applyMicMuteState: vi.fn().mockResolvedValue(undefined), + attemptAutoReconnect: vi.fn().mockResolvedValue(undefined), + teardownForReconnect: vi.fn(), + leaveVoice: vi.fn(), + setRoom: vi.fn(), + setReconnectAc: vi.fn(), + syncModuleRooms: vi.fn(), + onRemoteVideo: vi.fn(), + onRemoteVideoRemoved: vi.fn(), + onError: vi.fn(), + }; + + const deps: RoomEventDeps = { + getRoom: () => room as unknown as Room, + setRoom: spies.setRoom, + getCurrentChannelId: () => 12, + getAudioElements: () => audioElements, + getOnRemoteVideoCallback: () => spies.onRemoteVideo, + getOnRemoteVideoRemovedCallback: () => spies.onRemoteVideoRemoved, + getOnErrorCallback: () => spies.onError, + isConnecting: () => false, + getLatestToken: () => "tok", + getLastUrl: () => "wss://lk.example", + getLastDirectUrl: () => undefined, + setReconnectAc: spies.setReconnectAc, + syncModuleRooms: spies.syncModuleRooms, + teardownForReconnect: spies.teardownForReconnect, + leaveVoice: spies.leaveVoice, + applyMicMuteState: spies.applyMicMuteState, + attemptAutoReconnect: spies.attemptAutoReconnect, + ...over, + }; + + return { deps, handlers: createRoomEventHandlers(deps), audioElements, room, spies }; +} + +function audioTrack(): RemoteTrack { + return { + kind: Track.Kind.Audio, + sid: "AT_1", + detach: vi.fn(), + mediaStreamTrack: {} as MediaStreamTrack, + } as unknown as RemoteTrack; +} + +function videoTrack(): RemoteTrack & { detach: ReturnType } { + return { + kind: Track.Kind.Video, + sid: "VT_1", + detach: vi.fn(), + mediaStreamTrack: { id: "mst" } as MediaStreamTrack, + } as unknown as RemoteTrack & { detach: ReturnType }; +} + +function pub(source: Track.Source): RemoteTrackPublication { + return { source } as unknown as RemoteTrackPublication; +} + +function participant(identity: string): RemoteParticipant { + return { identity } as unknown as RemoteParticipant; +} + +beforeEach(() => { + voiceStore.setState((prev) => ({ ...prev, localMuted: false, localDeafened: false })); + vi.stubGlobal( + "MediaStream", + class { + tracks: unknown[]; + constructor(tracks: unknown[] = []) { + this.tracks = tracks; + } + }, + ); +}); + +// ── handleLocalTrackPublished ────────────────────────────────────────────── + +describe("handleLocalTrackPublished", () => { + it("re-applies mute when the local user is muted", () => { + const h = build(); + voiceStore.setState((prev) => ({ ...prev, localMuted: true })); + + h.handlers.handleLocalTrackPublished({ + source: Track.Source.Microphone, + } as LocalTrackPublication); + + expect(h.spies.applyMicMuteState).toHaveBeenCalledWith(true); + }); + + it("re-applies mute when the local user is deafened", () => { + const h = build(); + voiceStore.setState((prev) => ({ ...prev, localDeafened: true })); + + h.handlers.handleLocalTrackPublished({ + source: Track.Source.Microphone, + } as LocalTrackPublication); + + expect(h.spies.applyMicMuteState).toHaveBeenCalledWith(true); + }); + + it("does nothing when neither muted nor deafened", () => { + const h = build(); + + h.handlers.handleLocalTrackPublished({ + source: Track.Source.Microphone, + } as LocalTrackPublication); + + expect(h.spies.applyMicMuteState).not.toHaveBeenCalled(); + }); + + it("ignores non-microphone publications", () => { + const h = build(); + voiceStore.setState((prev) => ({ ...prev, localMuted: true })); + + h.handlers.handleLocalTrackPublished({ + source: Track.Source.ScreenShare, + } as LocalTrackPublication); + + expect(h.spies.applyMicMuteState).not.toHaveBeenCalled(); + }); + + it("swallows a rejected applyMicMuteState", async () => { + const applyMicMuteState = vi.fn().mockRejectedValue(new Error("no track")); + const h = build({ applyMicMuteState }); + voiceStore.setState((prev) => ({ ...prev, localMuted: true })); + + expect(() => { + h.handlers.handleLocalTrackPublished({ + source: Track.Source.Microphone, + } as LocalTrackPublication); + }).not.toThrow(); + await vi.waitFor(() => { + expect(applyMicMuteState).toHaveBeenCalled(); + }); + }); +}); + +// ── handleTrackSubscribed / Unsubscribed ─────────────────────────────────── + +describe("handleTrackSubscribed", () => { + it("routes audio tracks to the audio elements manager", () => { + const h = build(); + const track = audioTrack(); + const publication = pub(Track.Source.Microphone); + const p = participant("user-7:tok"); + + h.handlers.handleTrackSubscribed(track, publication, p); + + expect(h.audioElements.handleTrackSubscribedAudio).toHaveBeenCalledWith(track, publication, p); + expect(h.spies.onRemoteVideo).not.toHaveBeenCalled(); + }); + + it("hands camera video to the remote-video callback", () => { + const h = build(); + + h.handlers.handleTrackSubscribed( + videoTrack(), + pub(Track.Source.Camera), + participant("user-7:tok"), + ); + + expect(h.spies.onRemoteVideo).toHaveBeenCalledTimes(1); + const [userId, , isScreenshare] = h.spies.onRemoteVideo.mock.calls[0] as [ + number, + MediaStream, + boolean, + ]; + expect(userId).toBe(7); + expect(isScreenshare).toBe(false); + }); + + it("flags screenshare video as such", () => { + const h = build(); + + h.handlers.handleTrackSubscribed( + videoTrack(), + pub(Track.Source.ScreenShare), + participant("user-7:tok"), + ); + + expect(h.spies.onRemoteVideo.mock.calls[0]?.[2]).toBe(true); + }); + + it("skips video with an unparseable identity", () => { + const h = build(); + + h.handlers.handleTrackSubscribed( + videoTrack(), + pub(Track.Source.Camera), + participant("garbage"), + ); + + expect(h.spies.onRemoteVideo).not.toHaveBeenCalled(); + }); + + it("skips video when no callback is registered", () => { + const h = build({ getOnRemoteVideoCallback: () => null }); + + expect(() => { + h.handlers.handleTrackSubscribed( + videoTrack(), + pub(Track.Source.Camera), + participant("user-7:tok"), + ); + }).not.toThrow(); + }); +}); + +describe("handleTrackUnsubscribed", () => { + it("routes audio tracks to the audio elements manager", () => { + const h = build(); + const track = audioTrack(); + const publication = pub(Track.Source.Microphone); + const p = participant("user-7:tok"); + + h.handlers.handleTrackUnsubscribed(track, publication, p); + + expect(h.audioElements.handleTrackUnsubscribedAudio).toHaveBeenCalledWith( + track, + publication, + p, + ); + }); + + it("detaches the video element and notifies the removal callback", () => { + const h = build(); + const track = videoTrack(); + + h.handlers.handleTrackUnsubscribed(track, pub(Track.Source.Camera), participant("user-9:tok")); + + // Without detach the