From 867041b0946992d31ff7aff630c7b7ab2b1f4fcf Mon Sep 17 00:00:00 2001 From: jevb Date: Wed, 1 Apr 2026 11:40:31 +0200 Subject: [PATCH] =?UTF-8?q?test:=20client=20test=20updates=20=E2=80=94=20f?= =?UTF-8?q?ix=20failures=20+=20align=20with=20security=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update 111 test files to match security hardening changes: - acceptInvalidCerts now conditional on allowSelfSigned - Credential store no longer returns passwords over IPC - File type validation uses strict MIME allowlist - Search rate limiter timing adjustments - Dispatcher cleanup mock additions - Audio elements screenshare mute preservation 2962 tests passing across 110 test files. --- .../tauri-client/tests/browser/smoke.test.ts | 19 + .../tests/e2e/channel-sidebar.spec.ts | 6 +- .../tests/e2e/channel-switch-messages.spec.ts | 2 +- .../tests/e2e/connect-settings.spec.ts | 14 +- .../tauri-client/tests/e2e/dm-system.spec.ts | 10 +- .../tests/e2e/health-status.spec.ts | 14 +- Client/tauri-client/tests/e2e/helpers.ts | 277 +++--- .../tests/e2e/logout-flow.spec.ts | 12 +- .../tests/e2e/message-actions.spec.ts | 13 +- .../tests/e2e/message-edit-delete.spec.ts | 11 +- .../tests/e2e/message-list.spec.ts | 6 +- .../tests/e2e/message-send-flow.spec.ts | 11 +- .../tests/e2e/native-fixture-persistent.ts | 9 +- .../tauri-client/tests/e2e/native-fixture.ts | 9 +- .../tests/e2e/native/dm-system.spec.ts | 7 +- .../tests/e2e/native/overlays.spec.ts | 13 +- .../tests/e2e/native/reconnection.spec.ts | 22 +- .../tests/e2e/native/smoke.spec.ts | 27 +- .../e2e/native/theme-persistence.spec.ts | 19 +- .../tauri-client/tests/e2e/overlays.spec.ts | 20 +- .../tests/e2e/reconnection.spec.ts | 4 +- .../tests/e2e/register-flow.spec.ts | 36 +- .../tauri-client/tests/e2e/reply-flow.spec.ts | 5 +- .../tests/e2e/settings-overlay.spec.ts | 7 +- .../tests/e2e/theme-persistence.spec.ts | 20 +- .../tauri-client/tests/e2e/totp-flow.spec.ts | 48 +- .../tests/e2e/typing-indicator-ws.spec.ts | 8 +- .../tests/e2e/typing-indicator.spec.ts | 2 +- .../tests/e2e/voice-channel.spec.ts | 5 +- Client/tauri-client/tests/helpers/fixtures.ts | 78 +- Client/tauri-client/tests/helpers/mock-ws.ts | 10 +- .../tests/helpers/test-harness.ts | 4 +- .../tauri-client/tests/helpers/test-utils.ts | 2 +- .../tests/integration/stores.test.ts | 36 +- .../tests/unit/admin-actions.test.ts | 26 +- .../tests/unit/advanced-tab.test.ts | 28 +- .../tests/unit/appearance-tab.test.ts | 41 +- .../tests/unit/attachments-cache.test.ts | 50 +- .../tests/unit/attachments-render.test.ts | 23 +- .../tests/unit/audio-elements.test.ts | 8 +- .../tests/unit/audio-pipeline.test.ts | 223 +++-- .../tests/unit/cert-mismatch-modal.test.ts | 4 +- .../tests/unit/channel-controller.test.ts | 76 +- .../tests/unit/channel-sidebar.test.ts | 211 +++-- .../tests/unit/channels.store.test.ts | 258 +++--- .../tauri-client/tests/unit/chat-area.test.ts | 20 +- .../tests/unit/chat-header.test.ts | 8 +- Client/tauri-client/tests/unit/chat.test.ts | 118 ++- .../tests/unit/connect-page.test.ts | 40 +- .../tests/unit/connection-stats.test.ts | 98 +- .../tests/unit/context-menu.test.ts | 184 ++-- .../tests/unit/create-channel-modal.test.ts | 80 +- .../tests/unit/delete-channel-modal.test.ts | 27 +- .../tests/unit/device-manager.test.ts | 24 +- .../tests/unit/dispatcher.test.ts | 58 +- .../tauri-client/tests/unit/dm-store.test.ts | 23 +- .../tests/unit/edit-channel-modal.test.ts | 59 +- Client/tauri-client/tests/unit/embeds.test.ts | 249 ++++-- .../tests/unit/emoji-picker.test.ts | 4 +- .../tests/unit/file-upload.test.ts | 28 +- .../tests/unit/gif-picker.test.ts | 31 +- Client/tauri-client/tests/unit/icons.test.ts | 2 +- .../tests/unit/keybinds-tab.test.ts | 28 +- .../tests/unit/log-persistence.test.ts | 72 +- Client/tauri-client/tests/unit/logger.test.ts | 20 +- .../tauri-client/tests/unit/logs-tab.test.ts | 10 +- .../tests/unit/media-cache.test.ts | 11 +- .../tests/unit/media-visibility.test.ts | 208 ++--- Client/tauri-client/tests/unit/media.test.ts | 219 +++-- .../tests/unit/member-list.test.ts | 8 +- .../tests/unit/member-picker-modal.test.ts | 44 +- .../tests/unit/message-controller.test.ts | 18 +- .../tests/unit/message-input.test.ts | 79 +- .../tests/unit/message-list.test.ts | 26 +- .../tests/unit/messages.store.test.ts | 126 +-- .../tests/unit/modal-factory.test.ts | 20 +- .../tests/unit/notifications.test.ts | 27 +- .../tests/unit/overlay-managers.test.ts | 44 +- .../tests/unit/permissions.test.ts | 126 ++- .../tests/unit/pinned-messages.test.ts | 24 +- .../tauri-client/tests/unit/profiles.test.ts | 66 +- Client/tauri-client/tests/unit/ptt.test.ts | 22 +- .../tests/unit/quick-switcher.test.ts | 4 +- .../tests/unit/rate-limiter.test.ts | 4 +- .../tests/unit/reaction-controller.test.ts | 108 ++- .../tauri-client/tests/unit/reconcile.test.ts | 74 +- .../tauri-client/tests/unit/renderers.test.ts | 139 ++- .../tests/unit/rnnoise-worklet.test.ts | 32 +- .../tests/unit/search-overlay.test.ts | 65 +- .../tests/unit/server-panel.test.ts | 47 +- .../tests/unit/settings-overlay.test.ts | 129 ++- .../tests/unit/sidebar-dm-helpers.test.ts | 109 ++- Client/tauri-client/tests/unit/store.test.ts | 184 ++-- .../tests/unit/stored-appearance.test.ts | 8 +- .../tests/unit/stream-preview.test.ts | 8 +- Client/tauri-client/tests/unit/tenor.test.ts | 48 +- .../tests/unit/test-harness.test.ts | 7 +- Client/tauri-client/tests/unit/themes.test.ts | 2 +- .../tests/unit/toast-coverage.test.ts | 6 +- Client/tauri-client/tests/unit/toast.test.ts | 5 +- .../tests/unit/totp-settings.test.ts | 188 ++-- Client/tauri-client/tests/unit/types.test.ts | 81 +- .../tauri-client/tests/unit/ui.store.test.ts | 15 +- .../tauri-client/tests/unit/user-bar.test.ts | 17 +- .../tests/unit/video-grid.test.ts | 12 +- .../tests/unit/video-mode-controller.test.ts | 46 +- .../tests/unit/voice-audio-tab.test.ts | 118 +-- .../tests/unit/voice-channel.test.ts | 40 +- .../tests/unit/voice-disconnect.test.ts | 8 +- .../tests/unit/voice-widget.test.ts | 14 +- .../tests/unit/voice.store.test.ts | 30 +- Client/tauri-client/tests/unit/ws.test.ts | 844 +++++++++++------- 112 files changed, 3824 insertions(+), 2553 deletions(-) create mode 100644 Client/tauri-client/tests/browser/smoke.test.ts diff --git a/Client/tauri-client/tests/browser/smoke.test.ts b/Client/tauri-client/tests/browser/smoke.test.ts new file mode 100644 index 00000000..2e11b548 --- /dev/null +++ b/Client/tauri-client/tests/browser/smoke.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "vitest"; + +test("browser environment is available", () => { + expect(typeof window).toBe("object"); + expect(typeof document).toBe("object"); + expect(typeof document.createElement).toBe("function"); +}); + +test("real DOM APIs work", () => { + const div = document.createElement("div"); + div.innerHTML = "hello"; + document.body.appendChild(div); + + const span = document.querySelector("span"); + expect(span).not.toBeNull(); + expect(span!.textContent).toBe("hello"); + + div.remove(); +}); diff --git a/Client/tauri-client/tests/e2e/channel-sidebar.spec.ts b/Client/tauri-client/tests/e2e/channel-sidebar.spec.ts index 5090cf39..75f01d46 100644 --- a/Client/tauri-client/tests/e2e/channel-sidebar.spec.ts +++ b/Client/tauri-client/tests/e2e/channel-sidebar.spec.ts @@ -1,5 +1,9 @@ import { test, expect } from "@playwright/test"; -import { mockTauriFullSession, mockTauriFullSessionWithMessages, navigateToMainPage } from "./helpers"; +import { + mockTauriFullSession, + mockTauriFullSessionWithMessages, + navigateToMainPage, +} from "./helpers"; // --------------------------------------------------------------------------- // Tests: Channel Sidebar diff --git a/Client/tauri-client/tests/e2e/channel-switch-messages.spec.ts b/Client/tauri-client/tests/e2e/channel-switch-messages.spec.ts index 5744a142..2af4532b 100644 --- a/Client/tauri-client/tests/e2e/channel-switch-messages.spec.ts +++ b/Client/tauri-client/tests/e2e/channel-switch-messages.spec.ts @@ -77,7 +77,7 @@ test.describe("Channel Switch — Messages", () => { // The message should NOT be visible in current view await expect( - page.locator(".msg-text", { hasText: "Message on other channel" }) + page.locator(".msg-text", { hasText: "Message on other channel" }), ).not.toBeVisible(); }); diff --git a/Client/tauri-client/tests/e2e/connect-settings.spec.ts b/Client/tauri-client/tests/e2e/connect-settings.spec.ts index b42786f7..586a6d0c 100644 --- a/Client/tauri-client/tests/e2e/connect-settings.spec.ts +++ b/Client/tauri-client/tests/e2e/connect-settings.spec.ts @@ -7,12 +7,14 @@ import { buildTauriMockScript } from "./helpers"; test.describe("Connect Page — Settings Overlay", () => { test.beforeEach(async ({ page }) => { - await page.addInitScript(buildTauriMockScript({ - httpRoutes: [ - { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, - ], - simulateWsFlow: false, - })); + await page.addInitScript( + buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + ], + simulateWsFlow: false, + }), + ); await page.goto("/"); }); diff --git a/Client/tauri-client/tests/e2e/dm-system.spec.ts b/Client/tauri-client/tests/e2e/dm-system.spec.ts index ee5480b6..a1a8e195 100644 --- a/Client/tauri-client/tests/e2e/dm-system.spec.ts +++ b/Client/tauri-client/tests/e2e/dm-system.spec.ts @@ -95,9 +95,9 @@ async function navigateToMainPageWithDms(page: import("@playwright/test").Page): await waitForWsReady(page); // Wait for the DM section to render in the unified sidebar // In channels mode: DM section = .sidebar-dm-section, DM entries = [data-testid="dm-entry"] - await expect( - page.locator(".sidebar-dm-section, [data-testid='dm-entry']").first(), - ).toBeVisible({ timeout: 5_000 }); + await expect(page.locator(".sidebar-dm-section, [data-testid='dm-entry']").first()).toBeVisible({ + timeout: 5_000, + }); } // --------------------------------------------------------------------------- @@ -245,9 +245,7 @@ test.describe("DM System — WS Events", () => { }); // The unread count should increment. In channels mode, DM entries use .dm-unread-badge - await expect( - page.locator(".dm-unread-badge").first(), - ).toBeVisible({ timeout: 5_000 }); + await expect(page.locator(".dm-unread-badge").first()).toBeVisible({ timeout: 5_000 }); }); }); diff --git a/Client/tauri-client/tests/e2e/health-status.spec.ts b/Client/tauri-client/tests/e2e/health-status.spec.ts index 4185cfbc..33039b43 100644 --- a/Client/tauri-client/tests/e2e/health-status.spec.ts +++ b/Client/tauri-client/tests/e2e/health-status.spec.ts @@ -7,12 +7,14 @@ import { buildTauriMockScript } from "./helpers"; test.describe("Health Status Indicator", () => { test.beforeEach(async ({ page }) => { - await page.addInitScript(buildTauriMockScript({ - httpRoutes: [ - { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, - ], - simulateWsFlow: false, - })); + await page.addInitScript( + buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + ], + simulateWsFlow: false, + }), + ); await page.goto("/"); }); diff --git a/Client/tauri-client/tests/e2e/helpers.ts b/Client/tauri-client/tests/e2e/helpers.ts index 278cd367..9da61c42 100644 --- a/Client/tauri-client/tests/e2e/helpers.ts +++ b/Client/tauri-client/tests/e2e/helpers.ts @@ -160,7 +160,13 @@ export const MOCK_MESSAGES_RICH = { timestamp: "2026-03-15T10:03:00Z", edited_at: null, attachments: [ - { id: "1", filename: "screenshot.png", size: 102400, mime: "image/png", url: "/uploads/screenshot.png" }, + { + id: "1", + filename: "screenshot.png", + size: 102400, + mime: "image/png", + url: "/uploads/screenshot.png", + }, ], reactions: [], reply_to: null, @@ -175,7 +181,13 @@ export const MOCK_MESSAGES_RICH = { timestamp: "2026-03-15T10:03:30Z", edited_at: null, attachments: [ - { id: "2", filename: "report.pdf", size: 512000, mime: "application/pdf", url: "/uploads/report.pdf" }, + { + id: "2", + filename: "report.pdf", + size: 512000, + mime: "application/pdf", + url: "/uploads/report.pdf", + }, ], reactions: [], reply_to: null, @@ -557,13 +569,19 @@ export function buildTauriMockScript(opts: { // ---- WS commands ---- if (cmd === "ws_connect") { - ${opts.simulateWsFlow ? ` + ${ + opts.simulateWsFlow + ? ` setTimeout(() => __tauriEmitEvent("ws-state", "open"), 100); - ` : ""} + ` + : "" + } return; } if (cmd === "ws_send") { - ${opts.simulateWsFlow ? ` + ${ + opts.simulateWsFlow + ? ` try { var parsed = JSON.parse(args?.message || "{}"); if (parsed.type === "auth") { @@ -582,7 +600,9 @@ export function buildTauriMockScript(opts: { } } } catch (e) {} - ` : ""} + ` + : "" + } return; } if (cmd === "ws_disconnect") return; @@ -618,136 +638,164 @@ export function buildTauriMockScript(opts: { // --------------------------------------------------------------------------- export async function mockTauriConnect(page: Page): Promise { - await page.addInitScript(buildTauriMockScript({ - httpRoutes: [ - { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, - ], - simulateWsFlow: false, - })); + 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 { - 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, - })); + 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 { - 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 }, - { pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES }, - ], - simulateWsFlow: true, - })); + 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 }, + { pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES }, + ], + simulateWsFlow: true, + }), + ); } export async function mockTauriFullSessionWithMessages(page: Page): Promise { - 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, - }, - })); + 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 { - 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, - wsHandlers: voiceWsHandlers(), - readyOverrides: { - channels: MOCK_CHANNELS_WITH_CATEGORIES, - members: MOCK_MEMBERS_MULTI_ROLE, - voice_states: MOCK_VOICE_STATE, - }, - })); + 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, + wsHandlers: voiceWsHandlers(), + readyOverrides: { + channels: MOCK_CHANNELS_WITH_CATEGORIES, + members: MOCK_MEMBERS_MULTI_ROLE, + voice_states: MOCK_VOICE_STATE, + }, + }), + ); } export async function mockTauriFullSessionWithVoiceFailure(page: Page): Promise { - 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, - wsHandlers: [voiceJoinFailureHandler()], - readyOverrides: { - channels: MOCK_CHANNELS_WITH_CATEGORIES, - members: MOCK_MEMBERS_MULTI_ROLE, - voice_states: MOCK_VOICE_STATE, - }, - })); + 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, + wsHandlers: [voiceJoinFailureHandler()], + readyOverrides: { + channels: MOCK_CHANNELS_WITH_CATEGORIES, + members: MOCK_MEMBERS_MULTI_ROLE, + voice_states: MOCK_VOICE_STATE, + }, + }), + ); } export async function mockTauriFullSessionWithEcho(page: Page): Promise { - 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, - echoChatSend: true, - })); + 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, + echoChatSend: true, + }), + ); } export async function mockTauriFullSessionWithMessagesAndEcho(page: Page): Promise { - 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, - echoChatSend: true, - readyOverrides: { - channels: MOCK_CHANNELS_WITH_CATEGORIES, - members: MOCK_MEMBERS_MULTI_ROLE, - }, - })); + 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, + echoChatSend: true, + readyOverrides: { + channels: MOCK_CHANNELS_WITH_CATEGORIES, + members: MOCK_MEMBERS_MULTI_ROLE, + }, + }), + ); } export async function mockTauriFullSessionWithFailingMessages(page: Page): Promise { - 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: 500, body: { error: "INTERNAL_ERROR", message: "Failed to load messages" } }, - ], - simulateWsFlow: true, - })); + 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: 500, + body: { error: "INTERNAL_ERROR", message: "Failed to load messages" }, + }, + ], + simulateWsFlow: true, + }), + ); } export async function mockTauriLoginError(page: Page): Promise { - 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, - })); + 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, + }), + ); } // --------------------------------------------------------------------------- @@ -794,15 +842,14 @@ export async function switchSettingsTab(page: Page, tabName: string): Promise { +export async function emitWsEvent(page: Page, eventName: string, payload: unknown): Promise { 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)); + (window as any).__tauriEmitEvent( + event, + typeof data === "string" ? data : JSON.stringify(data), + ); }, { event: eventName, data: payload }, ); diff --git a/Client/tauri-client/tests/e2e/logout-flow.spec.ts b/Client/tauri-client/tests/e2e/logout-flow.spec.ts index 1ea5fac4..cdc3b4c4 100644 --- a/Client/tauri-client/tests/e2e/logout-flow.spec.ts +++ b/Client/tauri-client/tests/e2e/logout-flow.spec.ts @@ -12,15 +12,11 @@ test.describe("Logout Flow", () => { await navigateToMainPage(page); }); - test("clicking Log Out in settings returns to connect page", async ({ - page, - }) => { + test("clicking Log Out in settings returns to connect page", async ({ page }) => { // Open settings const settingsBtn = page.locator("button[aria-label='Settings']"); await settingsBtn.click(); - await expect( - page.locator(".settings-overlay.open"), - ).toBeVisible({ timeout: 3000 }); + await expect(page.locator(".settings-overlay.open")).toBeVisible({ timeout: 3000 }); // Click Log Out button const logoutBtn = page.locator(".settings-nav-item.danger", { @@ -37,9 +33,7 @@ test.describe("Logout Flow", () => { // Open settings and log out const settingsBtn = page.locator("button[aria-label='Settings']"); await settingsBtn.click(); - await expect( - page.locator(".settings-overlay.open"), - ).toBeVisible({ timeout: 3000 }); + await expect(page.locator(".settings-overlay.open")).toBeVisible({ timeout: 3000 }); const logoutBtn = page.locator(".settings-nav-item.danger", { hasText: "Log Out", diff --git a/Client/tauri-client/tests/e2e/message-actions.spec.ts b/Client/tauri-client/tests/e2e/message-actions.spec.ts index 38a54260..fe8995e6 100644 --- a/Client/tauri-client/tests/e2e/message-actions.spec.ts +++ b/Client/tauri-client/tests/e2e/message-actions.spec.ts @@ -3,10 +3,7 @@ * Tests: reply, edit, delete buttons on message hover. */ import { test, expect } from "@playwright/test"; -import { - mockTauriFullSessionWithMessagesAndEcho, - navigateToMainPage, -} from "./helpers"; +import { mockTauriFullSessionWithMessagesAndEcho, navigateToMainPage } from "./helpers"; test.describe("Message Actions Bar", () => { test.beforeEach(async ({ page }) => { @@ -69,9 +66,7 @@ test.describe("Message Actions Bar", () => { await expect(replyBar).toBeVisible({ timeout: 3000 }); }); - test("clicking Edit populates textarea with message content", async ({ - page, - }) => { + test("clicking Edit populates textarea with message content", async ({ page }) => { const ownMessage = page.locator("[data-testid='message-101']"); await ownMessage.hover(); @@ -99,9 +94,7 @@ test.describe("Message Reactions", () => { await navigateToMainPage(page); }); - test("reaction chips are visible on messages with reactions", async ({ - page, - }) => { + test("reaction chips are visible on messages with reactions", async ({ page }) => { const reactions = page.locator(".msg-reactions"); await expect(reactions.first()).toBeVisible(); }); diff --git a/Client/tauri-client/tests/e2e/message-edit-delete.spec.ts b/Client/tauri-client/tests/e2e/message-edit-delete.spec.ts index d5403764..f32a6868 100644 --- a/Client/tauri-client/tests/e2e/message-edit-delete.spec.ts +++ b/Client/tauri-client/tests/e2e/message-edit-delete.spec.ts @@ -3,10 +3,7 @@ * Covers: edit → save, edit → cancel, delete. */ import { test, expect } from "@playwright/test"; -import { - mockTauriFullSessionWithMessagesAndEcho, - navigateToMainPage, -} from "./helpers"; +import { mockTauriFullSessionWithMessagesAndEcho, navigateToMainPage } from "./helpers"; test.describe("Message Edit Flow", () => { test.beforeEach(async ({ page }) => { @@ -97,8 +94,8 @@ test.describe("Message Delete Flow", () => { await deleteBtn.click(); // Soft-delete: message stays in DOM but shows "[message deleted]" - await expect( - ownMessage.locator(".msg-text", { hasText: "[message deleted]" }), - ).toBeVisible({ timeout: 5000 }); + await expect(ownMessage.locator(".msg-text", { hasText: "[message deleted]" })).toBeVisible({ + timeout: 5000, + }); }); }); diff --git a/Client/tauri-client/tests/e2e/message-list.spec.ts b/Client/tauri-client/tests/e2e/message-list.spec.ts index 8d91e557..34964591 100644 --- a/Client/tauri-client/tests/e2e/message-list.spec.ts +++ b/Client/tauri-client/tests/e2e/message-list.spec.ts @@ -127,9 +127,9 @@ test.describe("Message List — Real-time", () => { } for (let i = 0; i < 3; i++) { - await expect( - page.locator(".msg-text", { hasText: `Rapid message ${i}` }) - ).toBeVisible({ timeout: 5_000 }); + await expect(page.locator(".msg-text", { hasText: `Rapid message ${i}` })).toBeVisible({ + timeout: 5_000, + }); } }); }); diff --git a/Client/tauri-client/tests/e2e/message-send-flow.spec.ts b/Client/tauri-client/tests/e2e/message-send-flow.spec.ts index 2daab154..a1988aa6 100644 --- a/Client/tauri-client/tests/e2e/message-send-flow.spec.ts +++ b/Client/tauri-client/tests/e2e/message-send-flow.spec.ts @@ -3,10 +3,7 @@ * Covers: type message → send → see it appear in message list. */ import { test, expect } from "@playwright/test"; -import { - mockTauriFullSessionWithEcho, - navigateToMainPage, -} from "./helpers"; +import { mockTauriFullSessionWithEcho, navigateToMainPage } from "./helpers"; test.describe("Message Send Flow", () => { test.beforeEach(async ({ page }) => { @@ -46,9 +43,9 @@ test.describe("Message Send Flow", () => { await textarea.press("Enter"); // Wait for the echo message to appear (confirms send happened) - await expect( - page.locator(".message .msg-text", { hasText: "Clear after send" }), - ).toBeVisible({ timeout: 5000 }); + await expect(page.locator(".message .msg-text", { hasText: "Clear after send" })).toBeVisible({ + timeout: 5000, + }); // Textarea should be empty await expect(textarea).toHaveValue(""); diff --git a/Client/tauri-client/tests/e2e/native-fixture-persistent.ts b/Client/tauri-client/tests/e2e/native-fixture-persistent.ts index 5a1fff5a..a01ffbeb 100644 --- a/Client/tauri-client/tests/e2e/native-fixture-persistent.ts +++ b/Client/tauri-client/tests/e2e/native-fixture-persistent.ts @@ -28,10 +28,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); /** Path to the built Tauri exe (release build). */ -const TAURI_EXE = path.resolve( - __dirname, - "../../src-tauri/target/release/owncord-client.exe", -); +const TAURI_EXE = path.resolve(__dirname, "../../src-tauri/target/release/owncord-client.exe"); /** CDP port for WebView2 remote debugging. */ const CDP_PORT = parseInt(process.env.CDP_PORT ?? "9222", 10); @@ -65,7 +62,7 @@ async function waitForCdpEndpoint(port: number, timeout: number): Promise throw new Error( `CDP endpoint at port ${port} did not become available within ${timeout}ms. ` + - `Make sure the Tauri app was built (npm run tauri build) and the exe exists at: ${TAURI_EXE}`, + `Make sure the Tauri app was built (npm run tauri build) and the exe exists at: ${TAURI_EXE}`, ); } @@ -119,7 +116,7 @@ async function acquirePersistentPage(workerIndex: number): Promise throw new Error( `CDP endpoint at port ${port} did not become available within ${timeout}ms. ` + - `Make sure the Tauri app was built (npm run tauri build) and the exe exists at: ${TAURI_EXE}`, + `Make sure the Tauri app was built (npm run tauri build) and the exe exists at: ${TAURI_EXE}`, ); } @@ -118,7 +115,7 @@ export const test = base.extend({ if (!fs.existsSync(TAURI_EXE)) { throw new Error( `Tauri exe not found at: ${TAURI_EXE}\n` + - `Run 'npm run tauri build' first to create the production build.`, + `Run 'npm run tauri build' first to create the production build.`, ); } diff --git a/Client/tauri-client/tests/e2e/native/dm-system.spec.ts b/Client/tauri-client/tests/e2e/native/dm-system.spec.ts index ac3cf0e4..e67680c8 100644 --- a/Client/tauri-client/tests/e2e/native/dm-system.spec.ts +++ b/Client/tauri-client/tests/e2e/native/dm-system.spec.ts @@ -6,12 +6,7 @@ */ import { test, expect } from "../native-fixture-persistent"; -import { - SKIP_SERVER, - hasCredentials, - ensureLoggedIn, - waitForMessages, -} from "./helpers"; +import { SKIP_SERVER, hasCredentials, ensureLoggedIn, waitForMessages } from "./helpers"; test.describe.configure({ mode: "serial" }); diff --git a/Client/tauri-client/tests/e2e/native/overlays.spec.ts b/Client/tauri-client/tests/e2e/native/overlays.spec.ts index 3c0c891c..ab4961b5 100644 --- a/Client/tauri-client/tests/e2e/native/overlays.spec.ts +++ b/Client/tauri-client/tests/e2e/native/overlays.spec.ts @@ -148,7 +148,9 @@ test.describe("Pinned Messages", () => { test("pin button triggers pin action", async ({ nativePage }) => { // The pin button may be a standalone icon, not a data-testid element. // From production screenshots: it's the 📌 icon in the chat header. - const pinBtn = nativePage.locator("[data-testid='pin-btn'], .pin-btn, button[aria-label='Pins']").first(); + const pinBtn = nativePage + .locator("[data-testid='pin-btn'], .pin-btn, button[aria-label='Pins']") + .first(); const exists = await pinBtn.isVisible().catch(() => false); test.skip(!exists, "No pin button in chat header"); @@ -170,14 +172,19 @@ test.describe("Pinned Messages", () => { }); test("pinned panel can be closed when available", async ({ nativePage }) => { - const pinBtn = nativePage.locator("[data-testid='pin-btn'], .pin-btn, button[aria-label='Pins']").first(); + const pinBtn = nativePage + .locator("[data-testid='pin-btn'], .pin-btn, button[aria-label='Pins']") + .first(); const exists = await pinBtn.isVisible().catch(() => false); test.skip(!exists, "No pin button in chat header"); await pinBtn.click(); const panel = nativePage.locator(".pinned-panel"); - const panelVisible = await panel.waitFor({ state: "visible", timeout: 5_000 }).then(() => true).catch(() => false); + const panelVisible = await panel + .waitFor({ state: "visible", timeout: 5_000 }) + .then(() => true) + .catch(() => false); test.skip(!panelVisible, "Pinned panel did not open (server may not have pin data)"); // Close via close button diff --git a/Client/tauri-client/tests/e2e/native/reconnection.spec.ts b/Client/tauri-client/tests/e2e/native/reconnection.spec.ts index 102a78e1..af680f39 100644 --- a/Client/tauri-client/tests/e2e/native/reconnection.spec.ts +++ b/Client/tauri-client/tests/e2e/native/reconnection.spec.ts @@ -7,11 +7,7 @@ */ import { test, expect } from "../native-fixture-persistent"; -import { - SKIP_SERVER, - hasCredentials, - ensureLoggedIn, -} from "./helpers"; +import { SKIP_SERVER, hasCredentials, ensureLoggedIn } from "./helpers"; // --------------------------------------------------------------------------- // Tests: Connection State @@ -29,11 +25,9 @@ test.describe("Reconnection (Native)", () => { test("reconnecting banner is NOT visible when connected", async ({ nativePage }) => { const banner = nativePage.locator(".reconnecting-banner"); - if (await banner.count() > 0) { + if ((await banner.count()) > 0) { // Banner element may exist in the DOM but should not be visible - const isVisible = await banner.evaluate((el) => - el.classList.contains("visible"), - ); + const isVisible = await banner.evaluate((el) => el.classList.contains("visible")); expect(isVisible).toBe(false); } // If the banner element doesn't exist at all, that's also fine @@ -91,7 +85,9 @@ test.describe("Reconnection (Native)", () => { } // 2. Message input is usable - const input = nativePage.locator("[data-testid='message-input'], .message-input-field, textarea.msg-box"); + const input = nativePage.locator( + "[data-testid='message-input'], .message-input-field, textarea.msg-box", + ); if (await input.isVisible().catch(() => false)) { await input.focus(); // Input should accept focus without errors @@ -99,10 +95,8 @@ test.describe("Reconnection (Native)", () => { // 3. No error banners visible const banner = nativePage.locator(".reconnecting-banner"); - if (await banner.count() > 0) { - const bannerVisible = await banner.evaluate((el) => - el.classList.contains("visible"), - ); + if ((await banner.count()) > 0) { + const bannerVisible = await banner.evaluate((el) => el.classList.contains("visible")); expect(bannerVisible).toBe(false); } }); diff --git a/Client/tauri-client/tests/e2e/native/smoke.spec.ts b/Client/tauri-client/tests/e2e/native/smoke.spec.ts index fb974967..19ce30bb 100644 --- a/Client/tauri-client/tests/e2e/native/smoke.spec.ts +++ b/Client/tauri-client/tests/e2e/native/smoke.spec.ts @@ -95,10 +95,7 @@ test.describe("Native App Server Connection", () => { test("health check via real Tauri HTTP plugin", async ({ nativePage }) => { // This test requires chatserver.exe to be running. // Skip if OWNCORD_SKIP_SERVER_TESTS is set. - test.skip( - !!process.env.OWNCORD_SKIP_SERVER_TESTS, - "Skipped: OWNCORD_SKIP_SERVER_TESTS is set", - ); + test.skip(!!process.env.OWNCORD_SKIP_SERVER_TESTS, "Skipped: OWNCORD_SKIP_SERVER_TESTS is set"); await nativePage.waitForLoadState("networkidle"); @@ -132,10 +129,7 @@ test.describe("Native App Server Connection", () => { // It does NOT require valid credentials — an "invalid credentials" error // from the server proves the round-trip works. // Skip if OWNCORD_SKIP_SERVER_TESTS is set. - test.skip( - !!process.env.OWNCORD_SKIP_SERVER_TESTS, - "Skipped: OWNCORD_SKIP_SERVER_TESTS is set", - ); + test.skip(!!process.env.OWNCORD_SKIP_SERVER_TESTS, "Skipped: OWNCORD_SKIP_SERVER_TESTS is set"); await nativePage.waitForLoadState("networkidle"); @@ -152,14 +146,14 @@ test.describe("Native App Server Connection", () => { // Wait for either: successful login OR server error response. // Both prove the real HTTP plugin made a round-trip to the server. const appLayout = nativePage.locator("[data-testid='app-layout']"); - const errorBanner = nativePage.locator(".error-banner, .error-message, .toast-error, [role='alert']"); + const errorBanner = nativePage.locator( + ".error-banner, .error-message, .toast-error, [role='alert']", + ); // Use Promise.race — whichever appears first const result = await Promise.race([ - appLayout.waitFor({ state: "visible", timeout: 20_000 }) - .then(() => "login-success" as const), - errorBanner.waitFor({ state: "visible", timeout: 20_000 }) - .then(() => "login-error" as const), + appLayout.waitFor({ state: "visible", timeout: 20_000 }).then(() => "login-success" as const), + errorBanner.waitFor({ state: "visible", timeout: 20_000 }).then(() => "login-error" as const), ]).catch(() => "timeout" as const); // Either outcome proves the real Tauri HTTP plugin works @@ -173,10 +167,9 @@ test.describe("Native App Credential Store", () => { // (save_credential, load_credential, delete_credential) const canInvoke = await nativePage.evaluate(async () => { try { - const result = await (window as any).__TAURI_INTERNALS__.invoke( - "load_credential", - { host: "e2e-test-nonexistent" }, - ); + const result = await (window as any).__TAURI_INTERNALS__.invoke("load_credential", { + host: "e2e-test-nonexistent", + }); // Should return null for nonexistent host, not throw return result === null || result === undefined; } catch (e: any) { diff --git a/Client/tauri-client/tests/e2e/native/theme-persistence.spec.ts b/Client/tauri-client/tests/e2e/native/theme-persistence.spec.ts index dd01449f..eef95c5a 100644 --- a/Client/tauri-client/tests/e2e/native/theme-persistence.spec.ts +++ b/Client/tauri-client/tests/e2e/native/theme-persistence.spec.ts @@ -6,12 +6,7 @@ */ import { test, expect } from "../native-fixture-persistent"; -import { - SKIP_SERVER, - hasCredentials, - ensureLoggedIn, - openSettings, -} from "./helpers"; +import { SKIP_SERVER, hasCredentials, ensureLoggedIn, openSettings } from "./helpers"; // --------------------------------------------------------------------------- // Helper: switch to a settings tab by name @@ -56,9 +51,7 @@ test.describe("Theme Persistence (Native)", () => { // Find and click an inactive theme for (let i = 0; i < count; i++) { - const isActive = await themeOptions.nth(i).evaluate((el) => - el.classList.contains("active"), - ); + const isActive = await themeOptions.nth(i).evaluate((el) => el.classList.contains("active")); if (!isActive) { await themeOptions.nth(i).click(); break; @@ -76,7 +69,9 @@ test.describe("Theme Persistence (Native)", () => { test("accent color picker applies CSS variable", async ({ nativePage }) => { // Look for accent color input - const colorInput = nativePage.locator("input[type='color'], .accent-color-input, .accent-picker"); + const colorInput = nativePage.locator( + "input[type='color'], .accent-color-input, .accent-picker", + ); if (await colorInput.isVisible().catch(() => false)) { await colorInput.fill("#ff0066"); @@ -126,7 +121,9 @@ test.describe("Theme Persistence (Native)", () => { }); test("compact mode toggle adds class to body", async ({ nativePage }) => { - const toggle = nativePage.locator(".setting-row", { hasText: "Compact Mode" }).locator(".toggle"); + const toggle = nativePage + .locator(".setting-row", { hasText: "Compact Mode" }) + .locator(".toggle"); await expect(toggle).toBeVisible(); const wasCompact = await nativePage.evaluate(() => diff --git a/Client/tauri-client/tests/e2e/overlays.spec.ts b/Client/tauri-client/tests/e2e/overlays.spec.ts index 326340f3..1e551d85 100644 --- a/Client/tauri-client/tests/e2e/overlays.spec.ts +++ b/Client/tauri-client/tests/e2e/overlays.spec.ts @@ -1,5 +1,9 @@ import { test, expect } from "@playwright/test"; -import { mockTauriFullSession, mockTauriFullSessionWithMessages, navigateToMainPage } from "./helpers"; +import { + mockTauriFullSession, + mockTauriFullSessionWithMessages, + navigateToMainPage, +} from "./helpers"; // --------------------------------------------------------------------------- // Tests: Quick Switcher (Ctrl+K) @@ -67,10 +71,9 @@ test.describe("Quick Switcher", () => { const initialCount = await page.locator(".quick-switcher__item").count(); await input.fill("general"); - await expect.poll( - async () => page.locator(".quick-switcher__item").count(), - { timeout: 2000 }, - ).toBeGreaterThan(0); + await expect + .poll(async () => page.locator(".quick-switcher__item").count(), { timeout: 2000 }) + .toBeGreaterThan(0); const filteredCount = await page.locator(".quick-switcher__item").count(); expect(filteredCount).toBeLessThanOrEqual(initialCount); @@ -162,10 +165,9 @@ test.describe("Emoji Picker", () => { // Search for a specific emoji character that exists in the grid await search.fill("\uD83D\uDE00"); - await expect.poll( - async () => page.locator(".ep-emoji").count(), - { timeout: 2000 }, - ).toBeGreaterThan(0); + await expect + .poll(async () => page.locator(".ep-emoji").count(), { timeout: 2000 }) + .toBeGreaterThan(0); const countAfter = await allEmojis.count(); // After filtering, should have fewer results diff --git a/Client/tauri-client/tests/e2e/reconnection.spec.ts b/Client/tauri-client/tests/e2e/reconnection.spec.ts index 2168e481..ab04c384 100644 --- a/Client/tauri-client/tests/e2e/reconnection.spec.ts +++ b/Client/tauri-client/tests/e2e/reconnection.spec.ts @@ -65,7 +65,7 @@ test.describe("Reconnection — Banner Visibility", () => { test("reconnecting banner is hidden when connected", async ({ page }) => { const banner = page.locator(".reconnecting-banner"); // The banner element exists but should NOT have the "visible" class - if (await banner.count() > 0) { + if ((await banner.count()) > 0) { await expect(banner).not.toHaveClass(/visible/); } }); @@ -83,7 +83,7 @@ test.describe("Reconnection — Banner Visibility", () => { await simulateReconnect(page); const banner = page.locator(".reconnecting-banner"); - if (await banner.count() > 0) { + if ((await banner.count()) > 0) { // After successful reconnect, banner should be hidden await expect(banner).not.toHaveClass(/visible/); } diff --git a/Client/tauri-client/tests/e2e/register-flow.spec.ts b/Client/tauri-client/tests/e2e/register-flow.spec.ts index 793a53dc..232ae5dd 100644 --- a/Client/tauri-client/tests/e2e/register-flow.spec.ts +++ b/Client/tauri-client/tests/e2e/register-flow.spec.ts @@ -11,23 +11,31 @@ const MOCK_REGISTER_RESPONSE = { }; async function mockRegisterSuccess(page: import("@playwright/test").Page): Promise { - await page.addInitScript(buildTauriMockScript({ - httpRoutes: [ - { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, - { pattern: "/api/v1/auth/register", status: 200, body: MOCK_REGISTER_RESPONSE }, - ], - simulateWsFlow: true, - })); + await page.addInitScript( + buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/register", status: 200, body: MOCK_REGISTER_RESPONSE }, + ], + simulateWsFlow: true, + }), + ); } async function mockRegisterConflict(page: import("@playwright/test").Page): Promise { - await page.addInitScript(buildTauriMockScript({ - httpRoutes: [ - { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, - { pattern: "/api/v1/auth/register", status: 409, body: { error: "USERNAME_TAKEN", message: "Username already exists" } }, - ], - simulateWsFlow: false, - })); + await page.addInitScript( + buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { + pattern: "/api/v1/auth/register", + status: 409, + body: { error: "USERNAME_TAKEN", message: "Username already exists" }, + }, + ], + simulateWsFlow: false, + }), + ); } async function switchToRegisterMode(page: import("@playwright/test").Page): Promise { diff --git a/Client/tauri-client/tests/e2e/reply-flow.spec.ts b/Client/tauri-client/tests/e2e/reply-flow.spec.ts index 31c016de..8a15fa45 100644 --- a/Client/tauri-client/tests/e2e/reply-flow.spec.ts +++ b/Client/tauri-client/tests/e2e/reply-flow.spec.ts @@ -3,10 +3,7 @@ * Covers: click reply → see reply bar → send reply → verify. */ import { test, expect } from "@playwright/test"; -import { - mockTauriFullSessionWithMessagesAndEcho, - navigateToMainPage, -} from "./helpers"; +import { mockTauriFullSessionWithMessagesAndEcho, navigateToMainPage } from "./helpers"; test.describe("Reply Flow", () => { test.beforeEach(async ({ page }) => { diff --git a/Client/tauri-client/tests/e2e/settings-overlay.spec.ts b/Client/tauri-client/tests/e2e/settings-overlay.spec.ts index 05d3fcf5..79058050 100644 --- a/Client/tauri-client/tests/e2e/settings-overlay.spec.ts +++ b/Client/tauri-client/tests/e2e/settings-overlay.spec.ts @@ -1,5 +1,10 @@ import { test, expect } from "@playwright/test"; -import { mockTauriFullSession, navigateToMainPage, openSettings, switchSettingsTab } from "./helpers"; +import { + mockTauriFullSession, + navigateToMainPage, + openSettings, + switchSettingsTab, +} from "./helpers"; // --------------------------------------------------------------------------- // Tests: Settings Overlay — structure diff --git a/Client/tauri-client/tests/e2e/theme-persistence.spec.ts b/Client/tauri-client/tests/e2e/theme-persistence.spec.ts index a9617366..b2f6e381 100644 --- a/Client/tauri-client/tests/e2e/theme-persistence.spec.ts +++ b/Client/tauri-client/tests/e2e/theme-persistence.spec.ts @@ -42,9 +42,7 @@ test.describe("Theme Persistence", () => { // Find a theme option that is NOT currently active let targetIndex = -1; for (let i = 0; i < count; i++) { - const isActive = await themeOptions.nth(i).evaluate((el) => - el.classList.contains("active"), - ); + const isActive = await themeOptions.nth(i).evaluate((el) => el.classList.contains("active")); if (!isActive) { targetIndex = i; break; @@ -72,9 +70,7 @@ test.describe("Theme Persistence", () => { await themeOptions.nth(1).click(); // Check localStorage for theme persistence - const storedTheme = await page.evaluate(() => - localStorage.getItem("owncord:theme:active"), - ); + const storedTheme = await page.evaluate(() => localStorage.getItem("owncord:theme:active")); expect(storedTheme).not.toBeNull(); expect(storedTheme!.length).toBeGreaterThan(0); }); @@ -85,9 +81,7 @@ test.describe("Theme Persistence", () => { await themeOptions.first().click(); // Read what was stored - const storedTheme = await page.evaluate(() => - localStorage.getItem("owncord:theme:active"), - ); + const storedTheme = await page.evaluate(() => localStorage.getItem("owncord:theme:active")); // Verify the body has the corresponding class if (storedTheme !== null) { @@ -144,16 +138,12 @@ test.describe("Accent Color Override", () => { // Wait for the value to be stored await expect(async () => { - const val = await page.evaluate(() => - localStorage.getItem("owncord:pref:accentColor"), - ); + const val = await page.evaluate(() => localStorage.getItem("owncord:pref:accentColor")); expect(val).not.toBeNull(); }).toPass({ timeout: 3_000 }); // Read the stored value - const stored = await page.evaluate(() => - localStorage.getItem("owncord:pref:accentColor"), - ); + const stored = await page.evaluate(() => localStorage.getItem("owncord:pref:accentColor")); // Navigate away from Appearance tab and back await switchSettingsTab(page, "Account"); diff --git a/Client/tauri-client/tests/e2e/totp-flow.spec.ts b/Client/tauri-client/tests/e2e/totp-flow.spec.ts index bd102fb6..24cd867b 100644 --- a/Client/tauri-client/tests/e2e/totp-flow.spec.ts +++ b/Client/tauri-client/tests/e2e/totp-flow.spec.ts @@ -3,31 +3,39 @@ * Covers: valid code submits, invalid code shows error, cancel returns to login. */ import { test, expect } from "@playwright/test"; -import { - buildTauriMockScript, - MOCK_LOGIN_2FA_RESPONSE, - MOCK_TOKEN, -} from "./helpers"; +import { buildTauriMockScript, MOCK_LOGIN_2FA_RESPONSE, MOCK_TOKEN } from "./helpers"; async function mockTotpSuccess(page: import("@playwright/test").Page): Promise { - 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 }, - { pattern: "/api/v1/auth/verify-totp", status: 200, body: { token: MOCK_TOKEN, requires_2fa: false } }, - ], - simulateWsFlow: true, - })); + 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 }, + { + pattern: "/api/v1/auth/verify-totp", + status: 200, + body: { token: MOCK_TOKEN, requires_2fa: false }, + }, + ], + simulateWsFlow: true, + }), + ); } async function mockTotpFailure(page: import("@playwright/test").Page): Promise { - 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 }, - { pattern: "/api/v1/auth/verify-totp", status: 401, body: { error: "INVALID_CODE", message: "Invalid verification code" } }, - ], - })); + 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 }, + { + pattern: "/api/v1/auth/verify-totp", + status: 401, + body: { error: "INVALID_CODE", message: "Invalid verification code" }, + }, + ], + }), + ); } async function loginToTotp(page: import("@playwright/test").Page): Promise { diff --git a/Client/tauri-client/tests/e2e/typing-indicator-ws.spec.ts b/Client/tauri-client/tests/e2e/typing-indicator-ws.spec.ts index f841609a..3018c9bb 100644 --- a/Client/tauri-client/tests/e2e/typing-indicator-ws.spec.ts +++ b/Client/tauri-client/tests/e2e/typing-indicator-ws.spec.ts @@ -1,9 +1,5 @@ import { test, expect } from "@playwright/test"; -import { - mockTauriFullSession, - navigateToMainPage, - emitWsMessage, -} from "./helpers"; +import { mockTauriFullSession, navigateToMainPage, emitWsMessage } from "./helpers"; test.describe("Typing Indicator — WebSocket", () => { test.beforeEach(async ({ page }) => { @@ -18,7 +14,7 @@ test.describe("Typing Indicator — WebSocket", () => { // Initially empty const typingBar = page.locator(".typing-bar"); - if (await typingBar.count() > 0) { + if ((await typingBar.count()) > 0) { await expect(typingBar).toBeEmpty(); } diff --git a/Client/tauri-client/tests/e2e/typing-indicator.spec.ts b/Client/tauri-client/tests/e2e/typing-indicator.spec.ts index 94f7dc87..a1fee5ed 100644 --- a/Client/tauri-client/tests/e2e/typing-indicator.spec.ts +++ b/Client/tauri-client/tests/e2e/typing-indicator.spec.ts @@ -19,7 +19,7 @@ test.describe("Typing Indicator", () => { test("typing bar is empty by default", async ({ page }) => { const typingBar = page.locator(".typing-bar"); - if (await typingBar.count() > 0) { + if ((await typingBar.count()) > 0) { // When empty, typing bar should have no visible dots text const text = await typingBar.textContent(); expect(text?.trim()).toBe(""); diff --git a/Client/tauri-client/tests/e2e/voice-channel.spec.ts b/Client/tauri-client/tests/e2e/voice-channel.spec.ts index ba00ece8..97c6cc21 100644 --- a/Client/tauri-client/tests/e2e/voice-channel.spec.ts +++ b/Client/tauri-client/tests/e2e/voice-channel.spec.ts @@ -4,10 +4,7 @@ * VoiceWidget shows connected users when in a voice channel. */ import { test, expect } from "@playwright/test"; -import { - mockTauriFullSessionWithVoice, - navigateToMainPage, -} from "./helpers"; +import { mockTauriFullSessionWithVoice, navigateToMainPage } from "./helpers"; test.describe("Voice Channel Items", () => { test.beforeEach(async ({ page }) => { diff --git a/Client/tauri-client/tests/helpers/fixtures.ts b/Client/tauri-client/tests/helpers/fixtures.ts index aa00c2ff..420dbc55 100644 --- a/Client/tauri-client/tests/helpers/fixtures.ts +++ b/Client/tauri-client/tests/helpers/fixtures.ts @@ -24,9 +24,7 @@ import type { // --------------------------------------------------------------------------- /** Create a MessageResponse with sensible defaults. */ -export function makeMessage( - overrides?: Partial, -): MessageResponse { +export function makeMessage(overrides?: Partial): MessageResponse { return { id: 1, channel_id: 1, @@ -44,9 +42,7 @@ export function makeMessage( } /** Create a MemberResponse with sensible defaults. */ -export function makeMember( - overrides?: Partial, -): MemberResponse { +export function makeMember(overrides?: Partial): MemberResponse { return { id: 1, username: "testuser", @@ -58,9 +54,7 @@ export function makeMember( } /** Create a ReadyChannel with sensible defaults. */ -export function makeChannel( - overrides?: Partial, -): ReadyChannel { +export function makeChannel(overrides?: Partial): ReadyChannel { return { id: 1, name: "general", @@ -74,9 +68,7 @@ export function makeChannel( } /** Create a ReactionSummary with sensible defaults. */ -export function makeReaction( - overrides?: Partial, -): ReactionSummary { +export function makeReaction(overrides?: Partial): ReactionSummary { return { emoji: "👍", count: 1, @@ -86,9 +78,7 @@ export function makeReaction( } /** Create a VoiceStatePayload with sensible defaults. */ -export function makeVoiceState( - overrides?: Partial, -): VoiceStatePayload { +export function makeVoiceState(overrides?: Partial): VoiceStatePayload { return { channel_id: 3, user_id: 1, @@ -107,9 +97,7 @@ export function makeVoiceState( // --------------------------------------------------------------------------- /** Create a MessageUser with sensible defaults. */ -export function makeMessageUser( - overrides?: Partial, -): MessageUser { +export function makeMessageUser(overrides?: Partial): MessageUser { return { id: 1, username: "testuser", @@ -119,9 +107,7 @@ export function makeMessageUser( } /** Create an Attachment with sensible defaults. */ -export function makeAttachment( - overrides?: Partial, -): Attachment { +export function makeAttachment(overrides?: Partial): Attachment { return { id: "att-1", filename: "image.png", @@ -149,9 +135,7 @@ export function makeChatMessagePayload( } /** Create a ReadyMember with sensible defaults. */ -export function makeReadyMember( - overrides?: Partial, -): ReadyMember { +export function makeReadyMember(overrides?: Partial): ReadyMember { return { id: 1, username: "testuser", @@ -163,9 +147,7 @@ export function makeReadyMember( } /** Create a ReadyVoiceState with sensible defaults. */ -export function makeReadyVoiceState( - overrides?: Partial, -): ReadyVoiceState { +export function makeReadyVoiceState(overrides?: Partial): ReadyVoiceState { return { channel_id: 3, user_id: 1, @@ -176,9 +158,7 @@ export function makeReadyVoiceState( } /** Create a ReadyRole with sensible defaults. */ -export function makeReadyRole( - overrides?: Partial, -): ReadyRole { +export function makeReadyRole(overrides?: Partial): ReadyRole { return { id: 1, name: "Member", @@ -193,25 +173,41 @@ export function makeReadyRole( // --------------------------------------------------------------------------- /** Create a full ReadyPayload fixture for integration tests. */ -export function makeReadyPayload( - overrides?: Partial, -): ReadyPayload { +export function makeReadyPayload(overrides?: Partial): ReadyPayload { return { channels: [ - makeChannel({ id: 1, name: "general", type: "text", position: 0, unread_count: 3, last_message_id: 100 }), - makeChannel({ id: 2, name: "random", type: "text", position: 1, unread_count: 0, last_message_id: 50 }), - makeChannel({ id: 3, name: "Voice Chat", type: "voice", category: "Voice Channels", position: 0 }), + makeChannel({ + id: 1, + name: "general", + type: "text", + position: 0, + unread_count: 3, + last_message_id: 100, + }), + makeChannel({ + id: 2, + name: "random", + type: "text", + position: 1, + unread_count: 0, + last_message_id: 50, + }), + makeChannel({ + id: 3, + name: "Voice Chat", + type: "voice", + category: "Voice Channels", + position: 0, + }), ], members: [ makeReadyMember({ id: 1, username: "admin", role: "admin", status: "online" }), makeReadyMember({ id: 2, username: "user1", role: "member", status: "online" }), ], - voice_states: [ - makeReadyVoiceState({ user_id: 1, channel_id: 3 }), - ], + voice_states: [makeReadyVoiceState({ user_id: 1, channel_id: 3 })], roles: [ - makeReadyRole({ id: 1, name: "Owner", color: "#e74c3c", permissions: 0x7FFFFFFF }), - makeReadyRole({ id: 2, name: "Admin", color: "#f1c40f", permissions: 0x3FFFFFFF }), + makeReadyRole({ id: 1, name: "Owner", color: "#e74c3c", permissions: 0x7fffffff }), + makeReadyRole({ id: 2, name: "Admin", color: "#f1c40f", permissions: 0x3fffffff }), makeReadyRole({ id: 3, name: "Member", color: null, permissions: 0x3 }), ], ...overrides, diff --git a/Client/tauri-client/tests/helpers/mock-ws.ts b/Client/tauri-client/tests/helpers/mock-ws.ts index 6117fa2b..10abe6ed 100644 --- a/Client/tauri-client/tests/helpers/mock-ws.ts +++ b/Client/tauri-client/tests/helpers/mock-ws.ts @@ -5,10 +5,7 @@ * requiring Tauri IPC or a real WebSocket connection. */ -import type { - ServerMessage, - ClientMessage, -} from "@lib/types"; +import type { ServerMessage, ClientMessage } from "@lib/types"; import type { ConnectionState, WsListener, CertMismatchListener } from "@lib/ws"; interface SentEnvelope { @@ -59,10 +56,7 @@ export function createMockWsClient() { return id; }, - on( - type: T, - listener: WsListener, - ): () => void { + on(type: T, listener: WsListener): () => void { if (!listeners.has(type)) { listeners.set(type, new Set()); } diff --git a/Client/tauri-client/tests/helpers/test-harness.ts b/Client/tauri-client/tests/helpers/test-harness.ts index 6cf54a8c..bd9d67b4 100644 --- a/Client/tauri-client/tests/helpers/test-harness.ts +++ b/Client/tauri-client/tests/helpers/test-harness.ts @@ -75,9 +75,7 @@ export function createTestHarness(): TestHarness { click(selector: string): void { const el = container.querySelector(selector) as HTMLElement | null; if (el === null) { - throw new Error( - `click("${selector}"): no element found in container`, - ); + throw new Error(`click("${selector}"): no element found in container`); } el.click(); }, diff --git a/Client/tauri-client/tests/helpers/test-utils.ts b/Client/tauri-client/tests/helpers/test-utils.ts index f2fa2b28..0e910a6a 100644 --- a/Client/tauri-client/tests/helpers/test-utils.ts +++ b/Client/tauri-client/tests/helpers/test-utils.ts @@ -57,7 +57,7 @@ const VOICE_INITIAL: VoiceState = { localCamera: false, localScreenshare: false, joinedAt: null, - listenOnly: false, + listenOnly: false, }; const UI_INITIAL: UiState = { diff --git a/Client/tauri-client/tests/integration/stores.test.ts b/Client/tauri-client/tests/integration/stores.test.ts index b57eac10..0be0c00d 100644 --- a/Client/tauri-client/tests/integration/stores.test.ts +++ b/Client/tauri-client/tests/integration/stores.test.ts @@ -46,10 +46,7 @@ function createMockWsClient(): MockWsClient { return crypto.randomUUID(); }, - on( - type: T, - listener: WsListener, - ): () => void { + on(type: T, listener: WsListener): () => void { if (!listeners.has(type)) { listeners.set(type, new Set()); } @@ -166,8 +163,24 @@ describe("Store integration via dispatcher", () => { it("populates channels, members, and voice stores from ready event", () => { ws.simulate("ready", { channels: [ - { id: 1, name: "general", type: "text", category: "Text Channels", position: 0, unread_count: 3, last_message_id: 100 }, - { id: 2, name: "random", type: "text", category: "Text Channels", position: 1, unread_count: 0, last_message_id: 50 }, + { + id: 1, + name: "general", + type: "text", + category: "Text Channels", + position: 0, + unread_count: 3, + last_message_id: 100, + }, + { + id: 2, + name: "random", + type: "text", + category: "Text Channels", + position: 1, + unread_count: 0, + last_message_id: 50, + }, { id: 3, name: "Voice Chat", type: "voice", category: "Voice Channels", position: 0 }, ], members: [ @@ -180,7 +193,7 @@ describe("Store integration via dispatcher", () => { { channel_id: 3, user_id: 2, muted: true, deafened: false }, ], roles: [ - { id: 1, name: "Admin", color: "#f1c40f", permissions: 0x3FFFFFFF }, + { id: 1, name: "Admin", color: "#f1c40f", permissions: 0x3fffffff }, { id: 2, name: "Member", color: null, permissions: 0x3 }, ], }); @@ -222,9 +235,7 @@ describe("Store integration via dispatcher", () => { { id: 1, name: "general", type: "text", category: null, position: 0, unread_count: 0 }, { id: 2, name: "random", type: "text", category: null, position: 1, unread_count: 0 }, ], - members: [ - { id: 10, username: "sender", avatar: null, role: "member", status: "online" }, - ], + members: [{ id: 10, username: "sender", avatar: null, role: "member", status: "online" }], voice_states: [], roles: [], }); @@ -435,10 +446,7 @@ describe("Store integration via dispatcher", () => { addPendingSend(correlationId, 1); // Simulate without an id - ws.simulate( - "chat_send_ok", - { message_id: 501, timestamp: "2026-03-15T13:01:00Z" }, - ); + ws.simulate("chat_send_ok", { message_id: 501, timestamp: "2026-03-15T13:01:00Z" }); // Pending send remains because no correlation ID was provided expect(messagesStore.getState().pendingSends.has(correlationId)).toBe(true); diff --git a/Client/tauri-client/tests/unit/admin-actions.test.ts b/Client/tauri-client/tests/unit/admin-actions.test.ts index 4a05927f..29c654e2 100644 --- a/Client/tauri-client/tests/unit/admin-actions.test.ts +++ b/Client/tauri-client/tests/unit/admin-actions.test.ts @@ -1,12 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - createMemberContextMenu, - createChannelContextMenu, -} from "@components/AdminActions"; -import type { - MemberContextMenuOptions, - ChannelContextMenuOptions, -} from "@components/AdminActions"; +import { createMemberContextMenu, createChannelContextMenu } from "@components/AdminActions"; +import type { MemberContextMenuOptions, ChannelContextMenuOptions } from "@components/AdminActions"; describe("AdminActions", () => { let container: HTMLDivElement; @@ -86,7 +80,9 @@ describe("AdminActions", () => { const { result } = makeMenu({ onKick }); const dangerItems = result.element.querySelectorAll(".context-menu__item--danger"); - const kickItem = Array.from(dangerItems).find((i) => i.textContent === "Kick") as HTMLDivElement; + const kickItem = Array.from(dangerItems).find( + (i) => i.textContent === "Kick", + ) as HTMLDivElement; // First click changes text to confirmation kickItem.click(); @@ -104,7 +100,9 @@ describe("AdminActions", () => { const { result } = makeMenu({ onBan }); const dangerItems = result.element.querySelectorAll(".context-menu__item--danger"); - const banItem = Array.from(dangerItems).find((i) => i.textContent === "Ban") as HTMLDivElement; + const banItem = Array.from(dangerItems).find( + (i) => i.textContent === "Ban", + ) as HTMLDivElement; banItem.click(); expect(banItem.textContent).toBe("Are you sure?"); @@ -165,7 +163,9 @@ describe("AdminActions", () => { const { result } = makeMenu({ onEdit }); const items = result.element.querySelectorAll(".context-menu__item"); - const editItem = Array.from(items).find((i) => i.textContent === "Edit Channel") as HTMLDivElement; + const editItem = Array.from(items).find( + (i) => i.textContent === "Edit Channel", + ) as HTMLDivElement; editItem.click(); expect(onEdit).toHaveBeenCalledOnce(); @@ -177,7 +177,9 @@ describe("AdminActions", () => { const { result } = makeMenu({ onCreate }); const items = result.element.querySelectorAll(".context-menu__item"); - const createItem = Array.from(items).find((i) => i.textContent === "Create Channel") as HTMLDivElement; + const createItem = Array.from(items).find( + (i) => i.textContent === "Create Channel", + ) as HTMLDivElement; createItem.click(); expect(onCreate).toHaveBeenCalledOnce(); diff --git a/Client/tauri-client/tests/unit/advanced-tab.test.ts b/Client/tauri-client/tests/unit/advanced-tab.test.ts index ed05b439..625a1d60 100644 --- a/Client/tauri-client/tests/unit/advanced-tab.test.ts +++ b/Client/tauri-client/tests/unit/advanced-tab.test.ts @@ -17,7 +17,15 @@ const { mockClearAttachmentCaches: vi.fn(), mockClearEmbedCaches: vi.fn(), mockClearMediaCaches: vi.fn(), - deleteDbState: { mode: "success" as "success" | "blocked-then-success" | "blocked-stuck" | "error" | "blocked-double" | "success-then-blocked" }, + deleteDbState: { + mode: "success" as + | "success" + | "blocked-then-success" + | "blocked-stuck" + | "error" + | "blocked-double" + | "success-then-blocked", + }, })); // Mock Tauri APIs @@ -152,9 +160,7 @@ describe("AdvancedTab — Clear All Cache", () => { const section = buildAdvancedTab(ac.signal); container.appendChild(section); const buttons = container.querySelectorAll("button.ac-btn"); - const btn = Array.from(buttons).find( - (b) => b.textContent === label, - ) as HTMLButtonElement; + const btn = Array.from(buttons).find((b) => b.textContent === label) as HTMLButtonElement; expect(btn).toBeDefined(); return btn; } @@ -166,7 +172,10 @@ describe("AdvancedTab — Clear All Cache", () => { } it("preserves owncord:profiles after Clear All Cache", async () => { - localStorage.setItem("owncord:profiles", JSON.stringify([{ name: "Local", host: "localhost" }])); + localStorage.setItem( + "owncord:profiles", + JSON.stringify([{ name: "Local", host: "localhost" }]), + ); localStorage.setItem("owncord:settings:fontSize", "16"); sessionStorage.setItem("some-session-key", "value"); @@ -204,7 +213,10 @@ describe("AdvancedTab — Clear All Cache", () => { it("preserves active and custom theme keys after Clear All Cache", async () => { localStorage.setItem("owncord:theme:active", "custom-sunrise"); - localStorage.setItem("owncord:theme:custom:custom-sunrise", JSON.stringify({ name: "custom-sunrise" })); + localStorage.setItem( + "owncord:theme:custom:custom-sunrise", + JSON.stringify({ name: "custom-sunrise" }), + ); localStorage.setItem("owncord:settings:accentColor", '"#00c8ff"'); const btn = getClearAllBtn(); @@ -470,7 +482,9 @@ describe("AdvancedTab — Toggles & Structure", () => { const section = buildAdvancedTab(ac.signal); container.appendChild(section); - const labels = Array.from(container.querySelectorAll(".setting-label")).map((l) => l.textContent); + const labels = Array.from(container.querySelectorAll(".setting-label")).map( + (l) => l.textContent, + ); expect(labels).toContain("Clear Image Cache"); expect(labels).toContain("Clear Log Files"); expect(labels).toContain("Clear All Cache & Restart"); diff --git a/Client/tauri-client/tests/unit/appearance-tab.test.ts b/Client/tauri-client/tests/unit/appearance-tab.test.ts index 0aedecb5..0a1947f7 100644 --- a/Client/tauri-client/tests/unit/appearance-tab.test.ts +++ b/Client/tauri-client/tests/unit/appearance-tab.test.ts @@ -1,17 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { buildAppearanceTab } from "@components/settings/AppearanceTab"; -const { - mockGetActiveThemeName, - mockLoadCustomTheme, - mockRestoreTheme, - mockApplyThemeByName, -} = vi.hoisted(() => ({ - mockGetActiveThemeName: vi.fn(() => "neon-glow"), - mockLoadCustomTheme: vi.fn((): { name: string; author: string; version: string; colors: Record } | null => null), - mockRestoreTheme: vi.fn(), - mockApplyThemeByName: vi.fn(), -})); +const { mockGetActiveThemeName, mockLoadCustomTheme, mockRestoreTheme, mockApplyThemeByName } = + vi.hoisted(() => ({ + mockGetActiveThemeName: vi.fn(() => "neon-glow"), + mockLoadCustomTheme: vi.fn( + (): { + name: string; + author: string; + version: string; + colors: Record; + } | null => null, + ), + mockRestoreTheme: vi.fn(), + mockApplyThemeByName: vi.fn(), + })); vi.mock("@stores/ui.store", () => ({ setTheme: vi.fn(), @@ -109,8 +112,8 @@ describe("AppearanceTab — Accessibility", () => { const section = buildAppearanceTab(ac.signal); container.appendChild(section); - const activeSwatch = container.querySelector('.accent-swatch.active') as HTMLElement; - const hexInput = container.querySelector('.accent-hex-row input') as HTMLInputElement; + const activeSwatch = container.querySelector(".accent-swatch.active") as HTMLElement; + const hexInput = container.querySelector(".accent-hex-row input") as HTMLInputElement; expect(activeSwatch).not.toBeNull(); expect(activeSwatch.title).toBe("#00c8ff"); @@ -123,8 +126,8 @@ describe("AppearanceTab — Accessibility", () => { const section = buildAppearanceTab(ac.signal); container.appendChild(section); - const activeSwatch = container.querySelector('.accent-swatch.active') as HTMLElement; - const hexInput = container.querySelector('.accent-hex-row input') as HTMLInputElement; + const activeSwatch = container.querySelector(".accent-swatch.active") as HTMLElement; + const hexInput = container.querySelector(".accent-hex-row input") as HTMLInputElement; expect(activeSwatch).not.toBeNull(); expect(activeSwatch.title).toBe("#5865f2"); @@ -143,8 +146,8 @@ describe("AppearanceTab — Accessibility", () => { const section = buildAppearanceTab(ac.signal); container.appendChild(section); - const activeSwatch = container.querySelector('.accent-swatch.active'); - const hexInput = container.querySelector('.accent-hex-row input') as HTMLInputElement; + const activeSwatch = container.querySelector(".accent-swatch.active"); + const hexInput = container.querySelector(".accent-hex-row input") as HTMLInputElement; expect(activeSwatch).toBeNull(); expect(hexInput.value).toBe("123456"); @@ -157,11 +160,11 @@ describe("AppearanceTab — Accessibility", () => { const tiles = container.querySelectorAll(".theme-opt"); const dark = tiles[0] as HTMLElement; - const hexInput = container.querySelector('.accent-hex-row input') as HTMLInputElement; + const hexInput = container.querySelector(".accent-hex-row input") as HTMLInputElement; dark.click(); - const activeSwatch = container.querySelector('.accent-swatch.active') as HTMLElement; + const activeSwatch = container.querySelector(".accent-swatch.active") as HTMLElement; expect(activeSwatch.title).toBe("#5865f2"); expect(hexInput.value).toBe("5865f2"); expect(hexInput.placeholder).toBe("5865f2"); diff --git a/Client/tauri-client/tests/unit/attachments-cache.test.ts b/Client/tauri-client/tests/unit/attachments-cache.test.ts index 2b16c0bd..7161240e 100644 --- a/Client/tauri-client/tests/unit/attachments-cache.test.ts +++ b/Client/tauri-client/tests/unit/attachments-cache.test.ts @@ -32,7 +32,11 @@ vi.stubGlobal("indexedDB", { onerror: null, objectStore: () => ({ get: () => { - const req: Record = { onsuccess: null, onerror: null, result: undefined }; + const req: Record = { + onsuccess: null, + onerror: null, + result: undefined, + }; Promise.resolve().then(() => { const fn = req.onsuccess as ((ev: Event) => void) | null; fn?.(new Event("success")); @@ -68,7 +72,11 @@ vi.stubGlobal("indexedDB", { }, }); -import { clearAttachmentCaches, fetchImageAsDataUrl, renderAttachment } from "../../src/components/message-list/attachments"; +import { + clearAttachmentCaches, + fetchImageAsDataUrl, + renderAttachment, +} from "../../src/components/message-list/attachments"; function imageResponse() { return { @@ -88,9 +96,12 @@ describe("attachment cache clearing", () => { it("does not repopulate caches from an in-flight fetch after clear", async () => { let resolveFetch: ((value: ReturnType) => void) | undefined; - fetchMock.mockImplementationOnce(() => new Promise((resolve) => { - resolveFetch = resolve; - })); + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); const pending = fetchImageAsDataUrl("https://example.com/image.png"); await vi.waitFor(() => { @@ -111,12 +122,18 @@ describe("attachment cache clearing", () => { let resolveFirst: ((value: ReturnType) => void) | undefined; let resolveSecond: ((value: ReturnType) => void) | undefined; fetchMock - .mockImplementationOnce(() => new Promise((resolve) => { - resolveFirst = resolve; - })) - .mockImplementationOnce(() => new Promise((resolve) => { - resolveSecond = resolve; - })); + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); const first = fetchImageAsDataUrl("https://example.com/image.png"); await vi.waitFor(() => { @@ -142,9 +159,12 @@ describe("attachment cache clearing", () => { it("stops showing a loading placeholder when a mid-fetch clear invalidates the result", async () => { let resolveFetch: ((value: ReturnType) => void) | undefined; - fetchMock.mockImplementationOnce(() => new Promise((resolve) => { - resolveFetch = resolve; - })); + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); const element = renderAttachment({ id: "att-1", @@ -169,4 +189,4 @@ describe("attachment cache clearing", () => { expect(placeholder.classList.contains("loading")).toBe(false); }); }); -}); \ No newline at end of file +}); diff --git a/Client/tauri-client/tests/unit/attachments-render.test.ts b/Client/tauri-client/tests/unit/attachments-render.test.ts index 30bbfe36..8351523f 100644 --- a/Client/tauri-client/tests/unit/attachments-render.test.ts +++ b/Client/tauri-client/tests/unit/attachments-render.test.ts @@ -37,10 +37,16 @@ vi.stubGlobal("indexedDB", { close: vi.fn(), transaction: () => { const tx: Record = { - oncomplete: null, onabort: null, onerror: null, + oncomplete: null, + onabort: null, + onerror: null, objectStore: () => ({ get: () => { - const req: Record = { onsuccess: null, onerror: null, result: undefined }; + const req: Record = { + onsuccess: null, + onerror: null, + result: undefined, + }; Promise.resolve().then(() => { const fn = req.onsuccess as ((ev: Event) => void) | null; fn?.(new Event("success")); @@ -59,7 +65,10 @@ vi.stubGlobal("indexedDB", { }; const req: Record = { - result: db, onsuccess: null, onerror: null, onupgradeneeded: null, + result: db, + onsuccess: null, + onerror: null, + onupgradeneeded: null, }; Promise.resolve().then(() => { const upgrade = req.onupgradeneeded as ((ev: Event) => void) | null; @@ -95,11 +104,15 @@ describe("resolveServerUrl", () => { }); it("returns absolute https URLs unchanged", () => { - expect(resolveServerUrl("https://cdn.example.com/file.png")).toBe("https://cdn.example.com/file.png"); + expect(resolveServerUrl("https://cdn.example.com/file.png")).toBe( + "https://cdn.example.com/file.png", + ); }); it("prepends server host for relative paths", () => { - expect(resolveServerUrl("/api/v1/attachments/1.png")).toBe("https://myserver.local:8443/api/v1/attachments/1.png"); + expect(resolveServerUrl("/api/v1/attachments/1.png")).toBe( + "https://myserver.local:8443/api/v1/attachments/1.png", + ); }); it("returns the path as-is when no server host is set", () => { diff --git a/Client/tauri-client/tests/unit/audio-elements.test.ts b/Client/tauri-client/tests/unit/audio-elements.test.ts index 615e7574..8dc68c60 100644 --- a/Client/tauri-client/tests/unit/audio-elements.test.ts +++ b/Client/tauri-client/tests/unit/audio-elements.test.ts @@ -507,9 +507,15 @@ describe("AudioElements", () => { expect(removeSs).toHaveBeenCalled(); }); - it("clears muted-by-user state", () => { + it("preserves muted-by-user state for reconnecting tracks", () => { elements.muteScreenshareAudio(42, true); elements.cleanupAllAudioElements(); + expect(elements.getScreenshareAudioMuted(42)).toBe(true); + }); + + it("clears muted-by-user state with full cleanup", () => { + elements.muteScreenshareAudio(42, true); + elements.cleanupAllAudioElementsFull(); expect(elements.getScreenshareAudioMuted(42)).toBe(false); }); }); diff --git a/Client/tauri-client/tests/unit/audio-pipeline.test.ts b/Client/tauri-client/tests/unit/audio-pipeline.test.ts index 3286d407..c05b6faf 100644 --- a/Client/tauri-client/tests/unit/audio-pipeline.test.ts +++ b/Client/tauri-client/tests/unit/audio-pipeline.test.ts @@ -357,7 +357,10 @@ describe("AudioPipeline", () => { }; vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal("MediaStream", vi.fn().mockImplementation(() => ({}))); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); mockRoom = { localParticipant: { @@ -508,9 +511,10 @@ describe("AudioPipeline", () => { pipeline.setInputVolume(50); expect(mockGainNode.gain.setTargetAtTime).toHaveBeenCalled(); - const call = mockGainNode.gain.setTargetAtTime.mock.calls[ - mockGainNode.gain.setTargetAtTime.mock.calls.length - 1 - ]; + const call = + mockGainNode.gain.setTargetAtTime.mock.calls[ + mockGainNode.gain.setTargetAtTime.mock.calls.length - 1 + ]; expect(call[0]).toBe(0.5); // inputGain = 50/100 = 0.5, not vadGated }); @@ -521,14 +525,20 @@ describe("AudioPipeline", () => { (pipeline as any).vadGated = true; pipeline.updatePipelineGain(); - const call = mockGainNode.gain.setTargetAtTime.mock.calls[ - mockGainNode.gain.setTargetAtTime.mock.calls.length - 1 - ]; + const call = + mockGainNode.gain.setTargetAtTime.mock.calls[ + mockGainNode.gain.setTargetAtTime.mock.calls.length - 1 + ]; expect(call[0]).toBe(0); }); it("handles AudioContext constructor failure gracefully", () => { - vi.stubGlobal("AudioContext", vi.fn(() => { throw new Error("AudioContext not supported"); })); + vi.stubGlobal( + "AudioContext", + vi.fn(() => { + throw new Error("AudioContext not supported"); + }), + ); pipeline.setRoom(mockRoom); // Should not throw expect(() => pipeline.setupAudioPipeline()).not.toThrow(); @@ -552,7 +562,9 @@ describe("AudioPipeline", () => { } as any; pipeline.setRoom(mockRoom); await pipeline.applyNoiseSuppressor(); - expect(mockRoom.localParticipant.getTrackPublication().track.setProcessor).not.toHaveBeenCalled(); + expect( + mockRoom.localParticipant.getTrackPublication().track.setProcessor, + ).not.toHaveBeenCalled(); }); it("attaches processor when track has none", async () => { @@ -587,7 +599,9 @@ describe("AudioPipeline", () => { } as any; pipeline.setRoom(mockRoom); await pipeline.removeNoiseSuppressor(); - expect(mockRoom.localParticipant.getTrackPublication().track.stopProcessor).not.toHaveBeenCalled(); + expect( + mockRoom.localParticipant.getTrackPublication().track.stopProcessor, + ).not.toHaveBeenCalled(); }); it("removes processor when track has one", async () => { @@ -648,11 +662,14 @@ describe("AudioPipeline", () => { function setupPipelineWithWorklet(workletBehavior: "success" | "fail"): void { mockGainNode = { gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), disconnect: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), }; mockAnalyserNode = { - fftSize: 0, smoothingTimeConstant: 0, - connect: vi.fn(), disconnect: vi.fn(), + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), getFloatTimeDomainData: vi.fn(), }; mockDestNode = { @@ -670,23 +687,30 @@ describe("AudioPipeline", () => { close: vi.fn().mockResolvedValue(undefined), state: "running", audioWorklet: { - addModule: workletBehavior === "success" - ? vi.fn().mockResolvedValue(undefined) - : vi.fn().mockRejectedValue(new Error("no worklet")), + addModule: + workletBehavior === "success" + ? vi.fn().mockResolvedValue(undefined) + : vi.fn().mockRejectedValue(new Error("no worklet")), }, }; // Mock AudioWorkletNode - vi.stubGlobal("AudioWorkletNode", vi.fn().mockImplementation(() => ({ - port: { - postMessage: vi.fn(), - onmessage: null as ((event: MessageEvent) => void) | null, - }, - connect: vi.fn(), - disconnect: vi.fn(), - }))); + vi.stubGlobal( + "AudioWorkletNode", + vi.fn().mockImplementation(() => ({ + port: { + postMessage: vi.fn(), + onmessage: null as ((event: MessageEvent) => void) | null, + }, + connect: vi.fn(), + disconnect: vi.fn(), + })), + ); vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal("MediaStream", vi.fn().mockImplementation(() => ({}))); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); mockRoom = { localParticipant: { @@ -694,7 +718,9 @@ describe("AudioPipeline", () => { track: { mediaStreamTrack: { id: "track" }, sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, - getProcessor: vi.fn(), setProcessor: vi.fn(), stopProcessor: vi.fn(), + getProcessor: vi.fn(), + setProcessor: vi.fn(), + stopProcessor: vi.fn(), }, }), }, @@ -789,9 +815,12 @@ describe("AudioPipeline", () => { it("falls back to setTimeout when AudioWorkletNode constructor throws", async () => { setupPipelineWithWorklet("success"); // Override AudioWorkletNode to throw - vi.stubGlobal("AudioWorkletNode", vi.fn().mockImplementation(() => { - throw new Error("AudioWorkletNode not supported"); - })); + vi.stubGlobal( + "AudioWorkletNode", + vi.fn().mockImplementation(() => { + throw new Error("AudioWorkletNode not supported"); + }), + ); pipeline.setRoom(mockRoom); pipeline.setupAudioPipeline(); @@ -819,15 +848,18 @@ describe("AudioPipeline", () => { dataArray.fill(0); const mockAnalyser = { - fftSize: 2048, smoothingTimeConstant: 0.3, - connect: vi.fn(), disconnect: vi.fn(), + fftSize: 2048, + smoothingTimeConstant: 0.3, + connect: vi.fn(), + disconnect: vi.fn(), getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { arr.set(dataArray); }), }; const mockGainNode = { gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), disconnect: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), }; const mockAudioCtx = { resume: vi.fn().mockResolvedValue(undefined), @@ -845,7 +877,10 @@ describe("AudioPipeline", () => { }; vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal("MediaStream", vi.fn().mockImplementation(() => ({}))); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { if (key === "voiceSensitivity") return 50; @@ -859,7 +894,9 @@ describe("AudioPipeline", () => { track: { mediaStreamTrack: { id: "track" }, sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, - getProcessor: vi.fn(), setProcessor: vi.fn(), stopProcessor: vi.fn(), + getProcessor: vi.fn(), + setProcessor: vi.fn(), + stopProcessor: vi.fn(), }, }), }, @@ -882,8 +919,10 @@ describe("AudioPipeline", () => { vi.useFakeTimers(); let isSilent = true; const mockAnalyser = { - fftSize: 2048, smoothingTimeConstant: 0.3, - connect: vi.fn(), disconnect: vi.fn(), + fftSize: 2048, + smoothingTimeConstant: 0.3, + connect: vi.fn(), + disconnect: vi.fn(), getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => { if (isSilent) { arr.fill(0); @@ -895,7 +934,8 @@ describe("AudioPipeline", () => { }; const mockGainNode = { gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), disconnect: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), }; const mockAudioCtx = { resume: vi.fn().mockResolvedValue(undefined), @@ -913,7 +953,10 @@ describe("AudioPipeline", () => { }; vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); - vi.stubGlobal("MediaStream", vi.fn().mockImplementation(() => ({}))); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { if (key === "voiceSensitivity") return 50; @@ -927,7 +970,9 @@ describe("AudioPipeline", () => { track: { mediaStreamTrack: { id: "track" }, sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) }, - getProcessor: vi.fn(), setProcessor: vi.fn(), stopProcessor: vi.fn(), + getProcessor: vi.fn(), + setProcessor: vi.fn(), + stopProcessor: vi.fn(), }, }), }, @@ -975,27 +1020,37 @@ describe("AudioPipeline", () => { } as any; // Stub AudioContext for setupAudioPipeline called internally - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue({ - resume: vi.fn().mockResolvedValue(undefined), - createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), - createAnalyser: vi.fn().mockReturnValue({ - fftSize: 0, smoothingTimeConstant: 0, connect: vi.fn(), disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), + vi.stubGlobal( + "AudioContext", + vi.fn().mockReturnValue({ + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), + createAnalyser: vi.fn().mockReturnValue({ + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }), + createGain: vi.fn().mockReturnValue({ + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }), + createMediaStreamDestination: vi.fn().mockReturnValue({ + stream: { getAudioTracks: vi.fn().mockReturnValue([]) }, + disconnect: vi.fn(), + }), + currentTime: 0, + close: vi.fn().mockResolvedValue(undefined), + state: "running", + audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, }), - createGain: vi.fn().mockReturnValue({ - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), disconnect: vi.fn(), - }), - createMediaStreamDestination: vi.fn().mockReturnValue({ - stream: { getAudioTracks: vi.fn().mockReturnValue([]) }, - disconnect: vi.fn(), - }), - currentTime: 0, - close: vi.fn().mockResolvedValue(undefined), - state: "running", - audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, - })); - vi.stubGlobal("MediaStream", vi.fn().mockImplementation(() => ({}))); + ); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); pipeline.setRoom(mockRoom); await pipeline.reapplyAudioProcessing(); @@ -1033,27 +1088,37 @@ describe("AudioPipeline", () => { }, } as any; - vi.stubGlobal("AudioContext", vi.fn().mockReturnValue({ - resume: vi.fn().mockResolvedValue(undefined), - createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), - createAnalyser: vi.fn().mockReturnValue({ - fftSize: 0, smoothingTimeConstant: 0, connect: vi.fn(), disconnect: vi.fn(), - getFloatTimeDomainData: vi.fn(), + vi.stubGlobal( + "AudioContext", + vi.fn().mockReturnValue({ + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), + createAnalyser: vi.fn().mockReturnValue({ + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }), + createGain: vi.fn().mockReturnValue({ + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }), + createMediaStreamDestination: vi.fn().mockReturnValue({ + stream: { getAudioTracks: vi.fn().mockReturnValue([]) }, + disconnect: vi.fn(), + }), + currentTime: 0, + close: vi.fn().mockResolvedValue(undefined), + state: "running", + audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, }), - createGain: vi.fn().mockReturnValue({ - gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, - connect: vi.fn(), disconnect: vi.fn(), - }), - createMediaStreamDestination: vi.fn().mockReturnValue({ - stream: { getAudioTracks: vi.fn().mockReturnValue([]) }, - disconnect: vi.fn(), - }), - currentTime: 0, - close: vi.fn().mockResolvedValue(undefined), - state: "running", - audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, - })); - vi.stubGlobal("MediaStream", vi.fn().mockImplementation(() => ({}))); + ); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation(() => ({})), + ); pipeline.setRoom(mockRoom); await pipeline.reapplyAudioProcessing(); diff --git a/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts b/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts index 341e74d4..85538869 100644 --- a/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts +++ b/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts @@ -13,9 +13,7 @@ describe("parseStoredFingerprint", () => { "Stored: 51:32:d1:f9:61:47:e4:cc:26:6f:3a:87\n" + "Current: 23:e4:00:61:11:f7:e5:12:eb:b9:2d:19\n" + "This may indicate a man-in-the-middle attack."; - expect(parseStoredFingerprint(msg)).toBe( - "51:32:d1:f9:61:47:e4:cc:26:6f:3a:87", - ); + expect(parseStoredFingerprint(msg)).toBe("51:32:d1:f9:61:47:e4:cc:26:6f:3a:87"); }); it("returns undefined for undefined message", () => { diff --git a/Client/tauri-client/tests/unit/channel-controller.test.ts b/Client/tauri-client/tests/unit/channel-controller.test.ts index 83a58f0b..4ebfa789 100644 --- a/Client/tauri-client/tests/unit/channel-controller.test.ts +++ b/Client/tauri-client/tests/unit/channel-controller.test.ts @@ -22,7 +22,14 @@ const { mockMessageInputDestroy: vi.fn(), mockTypingMount: vi.fn(), mockTypingDestroy: vi.fn(), - mockGetChannelMessages: vi.fn((): Array<{ id: number; content?: string; user?: { id: number; username: string }; deleted?: boolean }> => []), + mockGetChannelMessages: vi.fn( + (): Array<{ + id: number; + content?: string; + user?: { id: number; username: string }; + deleted?: boolean; + }> => [], + ), mockSetReplyTo: vi.fn(), mockStartEdit: vi.fn(), mockScrollToMessage: vi.fn(() => true), @@ -30,14 +37,21 @@ const { vi.mock("@lib/logger", () => ({ createLogger: () => ({ - debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), }), })); vi.mock("@lib/dom", () => ({ createElement: vi.fn((tag: string) => document.createElement(tag)), - clearChildren: vi.fn((el: HTMLElement) => { el.innerHTML = ""; }), - setText: vi.fn((el: HTMLElement, text: string) => { el.textContent = text; }), + clearChildren: vi.fn((el: HTMLElement) => { + el.innerHTML = ""; + }), + setText: vi.fn((el: HTMLElement, text: string) => { + el.textContent = text; + }), })); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- captured from mock factory, typed at call sites @@ -93,7 +107,16 @@ vi.mock("../../src/pages/main-page/ChatHeader", () => ({ })); const { mockDmStoreGetState, mockMembersStoreGetState } = vi.hoisted(() => ({ - mockDmStoreGetState: vi.fn(() => ({ channels: [] as Array<{ channelId: number; recipient: { id: number; username: string; avatar: string; status: string }; lastMessageId: number | null; lastMessage: string; lastMessageAt: string; unreadCount: number }> })), + mockDmStoreGetState: vi.fn(() => ({ + channels: [] as Array<{ + channelId: number; + recipient: { id: number; username: string; avatar: string; status: string }; + lastMessageId: number | null; + lastMessage: string; + lastMessageAt: string; + unreadCount: number; + }>, + })), mockMembersStoreGetState: vi.fn(() => ({ members: new Map() })), })); @@ -126,11 +149,22 @@ function makeSlots(): ChannelControllerOptions["slots"] { function makeOpts(overrides: Partial = {}): ChannelControllerOptions { return { - ws: { send: vi.fn(), getState: vi.fn(() => "connected") } as unknown as ChannelControllerOptions["ws"], - api: { uploadFile: vi.fn().mockResolvedValue({ id: 1, url: "/f/1", filename: "f.txt" }) } as unknown as ChannelControllerOptions["api"], - msgCtrl: { loadMessages: vi.fn(), loadOlderMessages: vi.fn() } as unknown as ChannelControllerOptions["msgCtrl"], + ws: { + send: vi.fn(), + getState: vi.fn(() => "connected"), + } as unknown as ChannelControllerOptions["ws"], + api: { + uploadFile: vi.fn().mockResolvedValue({ id: 1, url: "/f/1", filename: "f.txt" }), + } as unknown as ChannelControllerOptions["api"], + msgCtrl: { + loadMessages: vi.fn(), + loadOlderMessages: vi.fn(), + } as unknown as ChannelControllerOptions["msgCtrl"], pendingDeleteManager: { tryDelete: vi.fn(() => "pending" as const), cleanup: vi.fn() }, - reactionCtrl: { handleReaction: vi.fn(), destroy: vi.fn() } as unknown as ChannelControllerOptions["reactionCtrl"], + reactionCtrl: { + handleReaction: vi.fn(), + destroy: vi.fn(), + } as unknown as ChannelControllerOptions["reactionCtrl"], typingLimiter: { tryConsume: vi.fn(() => true) }, showToast: vi.fn(), getCurrentUserId: () => 1, @@ -242,7 +276,9 @@ describe("createChannelController", () => { describe("MessageList callbacks", () => { it("onDeleteClick sends delete on confirmed", () => { const opts = makeOpts(); - (opts.pendingDeleteManager.tryDelete as ReturnType).mockReturnValue("confirmed"); + (opts.pendingDeleteManager.tryDelete as ReturnType).mockReturnValue( + "confirmed", + ); const ctrl = createChannelController(opts); ctrl.mountChannel(42, "general"); @@ -372,11 +408,15 @@ describe("createChannelController", () => { it("onUploadFile shows toast on failure", async () => { const opts = makeOpts(); - (opts.api as unknown as { uploadFile: ReturnType }).uploadFile.mockRejectedValue(new Error("upload failed")); + ( + opts.api as unknown as { uploadFile: ReturnType } + ).uploadFile.mockRejectedValue(new Error("upload failed")); const ctrl = createChannelController(opts); ctrl.mountChannel(42, "general"); - await expect(capturedMessageInputOpts!.onUploadFile(new File(["x"], "test.txt"))).rejects.toThrow("upload failed"); + await expect( + capturedMessageInputOpts!.onUploadFile(new File(["x"], "test.txt")), + ).rejects.toThrow("upload failed"); expect(opts.showToast).toHaveBeenCalledWith("File upload failed", "error"); }); }); @@ -442,7 +482,9 @@ describe("createChannelController", () => { it("onPinClick pins a message and shows toast", async () => { const opts = makeOpts(); - (opts.api as unknown as { pinMessage: ReturnType }).pinMessage = vi.fn().mockResolvedValue(undefined); + (opts.api as unknown as { pinMessage: ReturnType }).pinMessage = vi + .fn() + .mockResolvedValue(undefined); const ctrl = createChannelController(opts); ctrl.mountChannel(42, "general"); @@ -456,7 +498,9 @@ describe("createChannelController", () => { it("onPinClick unpins a message and shows toast", async () => { const opts = makeOpts(); - (opts.api as unknown as { unpinMessage: ReturnType }).unpinMessage = vi.fn().mockResolvedValue(undefined); + (opts.api as unknown as { unpinMessage: ReturnType }).unpinMessage = vi + .fn() + .mockResolvedValue(undefined); const ctrl = createChannelController(opts); ctrl.mountChannel(42, "general"); @@ -470,7 +514,9 @@ describe("createChannelController", () => { it("onPinClick shows error toast on failure", async () => { const opts = makeOpts(); - (opts.api as unknown as { pinMessage: ReturnType }).pinMessage = vi.fn().mockRejectedValue(new Error("network error")); + (opts.api as unknown as { pinMessage: ReturnType }).pinMessage = vi + .fn() + .mockRejectedValue(new Error("network error")); const ctrl = createChannelController(opts); ctrl.mountChannel(42, "general"); diff --git a/Client/tauri-client/tests/unit/channel-sidebar.test.ts b/Client/tauri-client/tests/unit/channel-sidebar.test.ts index 2cad8a9d..ef66febb 100644 --- a/Client/tauri-client/tests/unit/channel-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/channel-sidebar.test.ts @@ -16,11 +16,7 @@ vi.mock("@lib/streamPreview", () => ({ })); import { createChannelSidebar } from "../../src/components/ChannelSidebar"; -import { - channelsStore, - setChannels, - setActiveChannel, -} from "../../src/stores/channels.store"; +import { channelsStore, setChannels, setActiveChannel } from "../../src/stores/channels.store"; import { authStore } from "../../src/stores/auth.store"; import { uiStore, toggleCategory } from "../../src/stores/ui.store"; import { voiceStore, updateVoiceState } from "../../src/stores/voice.store"; @@ -148,9 +144,7 @@ describe("ChannelSidebar", () => { const items = container.querySelectorAll(".channel-item"); expect(items.length).toBe(4); - const names = Array.from( - container.querySelectorAll(".ch-name"), - ).map((el) => el.textContent); + const names = Array.from(container.querySelectorAll(".ch-name")).map((el) => el.textContent); expect(names).toContain("general"); expect(names).toContain("random"); expect(names).toContain("voice-lobby"); @@ -179,9 +173,7 @@ describe("ChannelSidebar", () => { const ch1Before = channelsStore.getState().channels.get(1); expect(ch1Before?.unreadCount).toBe(2); - const firstItem = container.querySelector( - '[data-channel-id="1"]', - ) as HTMLElement; + const firstItem = container.querySelector('[data-channel-id="1"]') as HTMLElement; expect(firstItem).not.toBeNull(); firstItem.click(); @@ -195,9 +187,7 @@ describe("ChannelSidebar", () => { sidebar.mount(container); // Text Channels category should have 2 channels visible - const textChannelsBefore = container.querySelectorAll( - '.channel-item', - ); + const textChannelsBefore = container.querySelectorAll(".channel-item"); expect(textChannelsBefore.length).toBe(4); // Click the "Text Channels" category header to collapse @@ -251,9 +241,7 @@ describe("ChannelSidebar", () => { setActiveChannel(2); sidebar.mount(container); - const activeItem = container.querySelector( - '[data-channel-id="2"]', - ); + const activeItem = container.querySelector('[data-channel-id="2"]'); expect(activeItem?.classList.contains("active")).toBe(true); }); @@ -261,9 +249,7 @@ describe("ChannelSidebar", () => { setChannels(testChannels); sidebar.mount(container); - const voiceItem = container.querySelector( - '[data-channel-id="3"]', - ); + const voiceItem = container.querySelector('[data-channel-id="3"]'); const icon = voiceItem?.querySelector(".ch-icon"); expect(icon).not.toBeNull(); }); @@ -272,9 +258,7 @@ describe("ChannelSidebar", () => { setChannels(testChannels); sidebar.mount(container); - const voiceItem = container.querySelector( - '[data-channel-id="3"]', - ) as HTMLElement; + const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement; voiceItem.click(); // Should NOT set active channel @@ -287,9 +271,7 @@ describe("ChannelSidebar", () => { setChannels(testChannels); sidebar.mount(container); - const textItem = container.querySelector( - '[data-channel-id="1"]', - ) as HTMLElement; + const textItem = container.querySelector('[data-channel-id="1"]') as HTMLElement; textItem.click(); expect(channelsStore.getState().activeChannelId).toBe(1); @@ -301,9 +283,7 @@ describe("ChannelSidebar", () => { voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 })); sidebar.mount(container); - const voiceItem = container.querySelector( - '[data-channel-id="3"]', - ) as HTMLElement; + const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement; voiceItem.click(); expect(onVoiceLeave).toHaveBeenCalled(); @@ -315,7 +295,12 @@ describe("ChannelSidebar", () => { // Add a member so username resolves membersStore.setState((prev) => ({ ...prev, - members: new Map([[10, { id: 10, username: "Alice", avatar: null, role: "member", status: "online" as const }]]), + members: new Map([ + [ + 10, + { id: 10, username: "Alice", avatar: null, role: "member", status: "online" as const }, + ], + ]), })); updateVoiceState({ channel_id: 3, @@ -344,9 +329,7 @@ describe("ChannelSidebar", () => { voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 })); sidebar.mount(container); - const voiceItem = container.querySelector( - '[data-channel-id="3"]', - ); + const voiceItem = container.querySelector('[data-channel-id="3"]'); expect(voiceItem?.classList.contains("active")).toBe(true); }); @@ -640,11 +623,13 @@ describe("ChannelSidebar", () => { expect(channelEl).not.toBeNull(); // Dispatch right-click - channelEl.dispatchEvent(new MouseEvent("contextmenu", { - bubbles: true, - clientX: 100, - clientY: 200, - })); + channelEl.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + clientX: 100, + clientY: 200, + }), + ); const ctxMenu = document.querySelector('[data-testid="channel-context-menu"]'); expect(ctxMenu).not.toBeNull(); @@ -672,11 +657,13 @@ describe("ChannelSidebar", () => { sidebar.mount(container); const channelEl = container.querySelector('[data-channel-id="1"]') as HTMLElement; - channelEl.dispatchEvent(new MouseEvent("contextmenu", { - bubbles: true, - clientX: 100, - clientY: 200, - })); + channelEl.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + clientX: 100, + clientY: 200, + }), + ); const editItem = document.querySelector('[data-testid="ctx-edit-channel"]') as HTMLElement; editItem.click(); @@ -701,11 +688,13 @@ describe("ChannelSidebar", () => { sidebar.mount(container); const channelEl = container.querySelector('[data-channel-id="1"]') as HTMLElement; - channelEl.dispatchEvent(new MouseEvent("contextmenu", { - bubbles: true, - clientX: 100, - clientY: 200, - })); + channelEl.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + clientX: 100, + clientY: 200, + }), + ); const deleteItem = document.querySelector('[data-testid="ctx-delete-channel"]') as HTMLElement; deleteItem.click(); @@ -735,11 +724,13 @@ describe("ChannelSidebar", () => { sidebar.mount(container); const channelEl = container.querySelector('[data-channel-id="1"]') as HTMLElement; - channelEl.dispatchEvent(new MouseEvent("contextmenu", { - bubbles: true, - clientX: 100, - clientY: 200, - })); + channelEl.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + clientX: 100, + clientY: 200, + }), + ); // No context menu should appear for non-admin const ctxMenu = document.querySelector('[data-testid="channel-context-menu"]'); @@ -779,7 +770,9 @@ describe("ChannelSidebar", () => { setChannels(testChannels); sidebar.mount(container); - const addBtn = container.querySelector('[data-testid="create-channel-text-channels"]') as HTMLElement; + const addBtn = container.querySelector( + '[data-testid="create-channel-text-channels"]', + ) as HTMLElement; addBtn.click(); expect(onCreateChannel).toHaveBeenCalledWith("Text Channels"); @@ -801,7 +794,9 @@ describe("ChannelSidebar", () => { // All 4 channels visible before click expect(container.querySelectorAll(".channel-item").length).toBe(4); - const addBtn = container.querySelector('[data-testid="create-channel-text-channels"]') as HTMLElement; + const addBtn = container.querySelector( + '[data-testid="create-channel-text-channels"]', + ) as HTMLElement; addBtn.click(); // Category should NOT have collapsed (stopPropagation in the handler) @@ -857,11 +852,13 @@ describe("ChannelSidebar", () => { sidebar.mount(container); const voiceRow = container.querySelector(".voice-user-item") as HTMLElement; - voiceRow.dispatchEvent(new MouseEvent("contextmenu", { - bubbles: true, - clientX: 150, - clientY: 250, - })); + voiceRow.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + clientX: 150, + clientY: 250, + }), + ); const volMenu = document.querySelector(".user-vol-menu"); expect(volMenu).not.toBeNull(); @@ -1011,9 +1008,7 @@ describe("ChannelSidebar", () => { const userItems = container.querySelectorAll(".voice-user-item"); expect(userItems.length).toBe(2); - const names = Array.from(userItems).map( - (el) => el.querySelector(".vu-name")?.textContent, - ); + const names = Array.from(userItems).map((el) => el.querySelector(".vu-name")?.textContent); expect(names).toContain("UserA"); expect(names).toContain("UserB"); }); @@ -1052,7 +1047,23 @@ describe("ChannelSidebar", () => { voiceStore.setState(() => ({ currentChannelId: 3, voiceUsers: new Map([ - [3, new Map([[99, { userId: 99, username: "Streamer", speaking: false, muted: false, deafened: false, camera: false, screenshare: true }]])], + [ + 3, + new Map([ + [ + 99, + { + userId: 99, + username: "Streamer", + speaking: false, + muted: false, + deafened: false, + camera: false, + screenshare: true, + }, + ], + ]), + ], ]), voiceConfigs: new Map(), localMuted: false, @@ -1080,7 +1091,23 @@ describe("ChannelSidebar", () => { voiceStore.setState(() => ({ currentChannelId: 3, voiceUsers: new Map([ - [3, new Map([[99, { userId: 99, username: "Cammer", speaking: false, muted: false, deafened: false, camera: true, screenshare: false }]])], + [ + 3, + new Map([ + [ + 99, + { + userId: 99, + username: "Cammer", + speaking: false, + muted: false, + deafened: false, + camera: true, + screenshare: false, + }, + ], + ]), + ], ]), voiceConfigs: new Map(), localMuted: false, @@ -1114,7 +1141,23 @@ describe("ChannelSidebar", () => { voiceStore.setState(() => ({ currentChannelId: 3, voiceUsers: new Map([ - [3, new Map([[42, { userId: 42, username: "Me", speaking: false, muted: false, deafened: false, camera: true, screenshare: false }]])], + [ + 3, + new Map([ + [ + 42, + { + userId: 42, + username: "Me", + speaking: false, + muted: false, + deafened: false, + camera: true, + screenshare: false, + }, + ], + ]), + ], ]), voiceConfigs: new Map(), localMuted: false, @@ -1139,7 +1182,23 @@ describe("ChannelSidebar", () => { voiceStore.setState(() => ({ currentChannelId: 3, voiceUsers: new Map([ - [3, new Map([[1, { userId: 1, username: "User", speaking: false, muted: false, deafened: false, camera: false, screenshare: true }]])], + [ + 3, + new Map([ + [ + 1, + { + userId: 1, + username: "User", + speaking: false, + muted: false, + deafened: false, + camera: false, + screenshare: true, + }, + ], + ]), + ], ]), voiceConfigs: new Map(), localMuted: false, @@ -1164,7 +1223,23 @@ describe("ChannelSidebar", () => { voiceStore.setState(() => ({ currentChannelId: 3, voiceUsers: new Map([ - [3, new Map([[99, { userId: 99, username: "User", speaking: false, muted: false, deafened: false, camera: true, screenshare: false }]])], + [ + 3, + new Map([ + [ + 99, + { + userId: 99, + username: "User", + speaking: false, + muted: false, + deafened: false, + camera: true, + screenshare: false, + }, + ], + ]), + ], ]), voiceConfigs: new Map(), localMuted: false, diff --git a/Client/tauri-client/tests/unit/channels.store.test.ts b/Client/tauri-client/tests/unit/channels.store.test.ts index fab1a104..7614ffcf 100644 --- a/Client/tauri-client/tests/unit/channels.store.test.ts +++ b/Client/tauri-client/tests/unit/channels.store.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach } from "vitest"; import { channelsStore, setChannels, @@ -13,12 +13,8 @@ import { getChannelsByCategory, incrementUnread, clearUnread, -} from '../../src/stores/channels.store'; -import type { - ReadyChannel, - ChannelCreatePayload, - ChannelUpdatePayload, -} from '../../src/lib/types'; +} from "../../src/stores/channels.store"; +import type { ReadyChannel, ChannelCreatePayload, ChannelUpdatePayload } from "../../src/lib/types"; function resetStore(): void { channelsStore.setState(() => ({ @@ -29,24 +25,40 @@ function resetStore(): void { } const readyChannels: ReadyChannel[] = [ - { id: 1, name: 'general', type: 'text', category: 'Text', position: 0, unread_count: 3, last_message_id: 100 }, - { id: 2, name: 'voice-lobby', type: 'voice', category: 'Voice', position: 0 }, - { id: 3, name: 'announcements', type: 'announcement', category: 'Text', position: 1, unread_count: 0, last_message_id: 50 }, + { + id: 1, + name: "general", + type: "text", + category: "Text", + position: 0, + unread_count: 3, + last_message_id: 100, + }, + { id: 2, name: "voice-lobby", type: "voice", category: "Voice", position: 0 }, + { + id: 3, + name: "announcements", + type: "announcement", + category: "Text", + position: 1, + unread_count: 0, + last_message_id: 50, + }, ]; -describe('channels store', () => { +describe("channels store", () => { beforeEach(() => { resetStore(); }); - it('has empty initial state', () => { + it("has empty initial state", () => { const state = channelsStore.getState(); expect(state.channels.size).toBe(0); expect(state.activeChannelId).toBeNull(); }); - describe('setChannels', () => { - it('populates channels from ready payload', () => { + describe("setChannels", () => { + it("populates channels from ready payload", () => { setChannels(readyChannels); const state = channelsStore.getState(); @@ -55,9 +67,9 @@ describe('channels store', () => { const general = state.channels.get(1); expect(general).toEqual({ id: 1, - name: 'general', - type: 'text', - category: 'Text', + name: "general", + type: "text", + category: "Text", position: 0, unreadCount: 3, lastMessageId: 100, @@ -66,32 +78,32 @@ describe('channels store', () => { const voice = state.channels.get(2); expect(voice).toEqual({ id: 2, - name: 'voice-lobby', - type: 'voice', - category: 'Voice', + name: "voice-lobby", + type: "voice", + category: "Voice", position: 0, unreadCount: 0, lastMessageId: null, }); }); - it('defaults unread_count to 0 and last_message_id to null', () => { - setChannels([{ id: 10, name: 'test', type: 'text', category: null, position: 0 }]); + it("defaults unread_count to 0 and last_message_id to null", () => { + setChannels([{ id: 10, name: "test", type: "text", category: null, position: 0 }]); const ch = channelsStore.getState().channels.get(10); expect(ch?.unreadCount).toBe(0); expect(ch?.lastMessageId).toBeNull(); }); }); - describe('addChannel', () => { - it('adds a new channel', () => { + describe("addChannel", () => { + it("adds a new channel", () => { setChannels(readyChannels); const payload: ChannelCreatePayload = { id: 4, - name: 'new-channel', - type: 'text', - category: 'Text', + name: "new-channel", + type: "text", + category: "Text", position: 2, }; addChannel(payload); @@ -102,20 +114,20 @@ describe('channels store', () => { const added = state.channels.get(4); expect(added).toEqual({ id: 4, - name: 'new-channel', - type: 'text', - category: 'Text', + name: "new-channel", + type: "text", + category: "Text", position: 2, unreadCount: 0, lastMessageId: null, }); }); - it('does not mutate the previous channels map', () => { + it("does not mutate the previous channels map", () => { setChannels(readyChannels); const before = channelsStore.getState().channels; - addChannel({ id: 5, name: 'extra', type: 'text', category: null, position: 0 }); + addChannel({ id: 5, name: "extra", type: "text", category: null, position: 0 }); const after = channelsStore.getState().channels; expect(before).not.toBe(after); @@ -124,53 +136,53 @@ describe('channels store', () => { }); }); - describe('updateChannel', () => { - it('updates name immutably', () => { + describe("updateChannel", () => { + it("updates name immutably", () => { setChannels(readyChannels); const before = channelsStore.getState().channels.get(1); - const update: ChannelUpdatePayload = { id: 1, name: 'renamed' }; + const update: ChannelUpdatePayload = { id: 1, name: "renamed" }; updateChannel(update); const after = channelsStore.getState().channels.get(1); - expect(after?.name).toBe('renamed'); + expect(after?.name).toBe("renamed"); expect(after?.position).toBe(0); // unchanged expect(before).not.toBe(after); }); - it('updates position immutably', () => { + it("updates position immutably", () => { setChannels(readyChannels); updateChannel({ id: 1, position: 5 }); const ch = channelsStore.getState().channels.get(1); expect(ch?.position).toBe(5); - expect(ch?.name).toBe('general'); // unchanged + expect(ch?.name).toBe("general"); // unchanged }); - it('updates both name and position', () => { + it("updates both name and position", () => { setChannels(readyChannels); - updateChannel({ id: 1, name: 'new-name', position: 10 }); + updateChannel({ id: 1, name: "new-name", position: 10 }); const ch = channelsStore.getState().channels.get(1); - expect(ch?.name).toBe('new-name'); + expect(ch?.name).toBe("new-name"); expect(ch?.position).toBe(10); }); - it('is a no-op for unknown channel id', () => { + it("is a no-op for unknown channel id", () => { setChannels(readyChannels); const before = channelsStore.getState(); - updateChannel({ id: 999, name: 'ghost' }); + updateChannel({ id: 999, name: "ghost" }); const after = channelsStore.getState(); expect(after).toBe(before); }); }); - describe('removeChannel', () => { - it('removes a channel', () => { + describe("removeChannel", () => { + it("removes a channel", () => { setChannels(readyChannels); removeChannel(1); @@ -180,7 +192,7 @@ describe('channels store', () => { expect(state.channels.has(1)).toBe(false); }); - it('clears activeChannelId if removed channel was active', () => { + it("clears activeChannelId if removed channel was active", () => { setChannels(readyChannels); setActiveChannel(1); expect(channelsStore.getState().activeChannelId).toBe(1); @@ -190,7 +202,7 @@ describe('channels store', () => { expect(channelsStore.getState().activeChannelId).toBeNull(); }); - it('preserves activeChannelId if removed channel was not active', () => { + it("preserves activeChannelId if removed channel was not active", () => { setChannels(readyChannels); setActiveChannel(2); @@ -200,8 +212,8 @@ describe('channels store', () => { }); }); - describe('setActiveChannel', () => { - it('sets active channel id', () => { + describe("setActiveChannel", () => { + it("sets active channel id", () => { setChannels(readyChannels); setActiveChannel(2); @@ -209,7 +221,7 @@ describe('channels store', () => { expect(channelsStore.getState().activeChannelId).toBe(2); }); - it('sets active channel to null', () => { + it("sets active channel to null", () => { setChannels(readyChannels); setActiveChannel(1); @@ -218,7 +230,7 @@ describe('channels store', () => { expect(channelsStore.getState().activeChannelId).toBeNull(); }); - it('clears unread count for the activated channel', () => { + it("clears unread count for the activated channel", () => { setChannels(readyChannels); // channel 1 starts with unreadCount: 3 expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(3); @@ -228,7 +240,7 @@ describe('channels store', () => { expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(0); }); - it('does not mutate channels map when clearing unread', () => { + it("does not mutate channels map when clearing unread", () => { setChannels(readyChannels); const before = channelsStore.getState().channels; @@ -240,7 +252,7 @@ describe('channels store', () => { expect(after.get(2)).toBe(before.get(2)); }); - it('skips channels map update when unread is already 0', () => { + it("skips channels map update when unread is already 0", () => { setChannels(readyChannels); // channel 2 has unreadCount: 0 const before = channelsStore.getState().channels; @@ -252,70 +264,68 @@ describe('channels store', () => { }); }); - describe('getActiveChannel', () => { - it('returns null when no active channel', () => { + describe("getActiveChannel", () => { + it("returns null when no active channel", () => { expect(getActiveChannel()).toBeNull(); }); - it('returns the active Channel object', () => { + it("returns the active Channel object", () => { setChannels(readyChannels); setActiveChannel(1); const active = getActiveChannel(); expect(active).toEqual({ id: 1, - name: 'general', - type: 'text', - category: 'Text', + name: "general", + type: "text", + category: "Text", position: 0, unreadCount: 0, lastMessageId: 100, }); }); - it('returns null if activeChannelId refers to a non-existent channel', () => { + it("returns null if activeChannelId refers to a non-existent channel", () => { setActiveChannel(999); expect(getActiveChannel()).toBeNull(); }); }); - describe('getChannelsByCategory', () => { - it('groups channels by category and sorts by position', () => { + describe("getChannelsByCategory", () => { + it("groups channels by category and sorts by position", () => { setChannels(readyChannels); const grouped = getChannelsByCategory(); expect(grouped.size).toBe(2); - const textChannels = grouped.get('Text'); + const textChannels = grouped.get("Text"); expect(textChannels).toHaveLength(2); - expect(textChannels?.[0]?.name).toBe('general'); // position 0 - expect(textChannels?.[1]?.name).toBe('announcements'); // position 1 + expect(textChannels?.[0]?.name).toBe("general"); // position 0 + expect(textChannels?.[1]?.name).toBe("announcements"); // position 1 - const voiceChannels = grouped.get('Voice'); + const voiceChannels = grouped.get("Voice"); expect(voiceChannels).toHaveLength(1); - expect(voiceChannels?.[0]?.name).toBe('voice-lobby'); + expect(voiceChannels?.[0]?.name).toBe("voice-lobby"); }); - it('handles null category', () => { - setChannels([ - { id: 1, name: 'uncategorized', type: 'text', category: null, position: 0 }, - ]); + it("handles null category", () => { + setChannels([{ id: 1, name: "uncategorized", type: "text", category: null, position: 0 }]); const grouped = getChannelsByCategory(); expect(grouped.has(null)).toBe(true); expect(grouped.get(null)).toHaveLength(1); }); - it('returns empty map when no channels', () => { + it("returns empty map when no channels", () => { const grouped = getChannelsByCategory(); expect(grouped.size).toBe(0); }); }); - describe('incrementUnread', () => { - it('increments unread count for a channel', () => { + describe("incrementUnread", () => { + it("increments unread count for a channel", () => { setChannels(readyChannels); incrementUnread(1); @@ -324,7 +334,7 @@ describe('channels store', () => { expect(ch?.unreadCount).toBe(4); // was 3 }); - it('skips increment for the active channel', () => { + it("skips increment for the active channel", () => { setChannels(readyChannels); setActiveChannel(1); // setActiveChannel clears unread, so it's now 0 @@ -336,7 +346,7 @@ describe('channels store', () => { expect(ch?.unreadCount).toBe(0); // unchanged — active channel skips increment }); - it('is a no-op for unknown channel id', () => { + it("is a no-op for unknown channel id", () => { setChannels(readyChannels); const before = channelsStore.getState(); @@ -346,8 +356,8 @@ describe('channels store', () => { }); }); - describe('clearUnread', () => { - it('resets unread count to 0', () => { + describe("clearUnread", () => { + it("resets unread count to 0", () => { setChannels(readyChannels); expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(3); @@ -356,7 +366,7 @@ describe('channels store', () => { expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(0); }); - it('is a no-op for unknown channel id', () => { + it("is a no-op for unknown channel id", () => { setChannels(readyChannels); const before = channelsStore.getState(); @@ -366,47 +376,47 @@ describe('channels store', () => { }); }); - describe('setRoles', () => { - it('stores roles from ready payload', () => { + describe("setRoles", () => { + it("stores roles from ready payload", () => { const roles = [ - { id: 1, name: 'admin', color: '#ff0000', permissions: 0 }, - { id: 2, name: 'member', color: '#00ff00', permissions: 0 }, + { id: 1, name: "admin", color: "#ff0000", permissions: 0 }, + { id: 2, name: "member", color: "#00ff00", permissions: 0 }, ]; setRoles(roles); expect(channelsStore.getState().roles).toEqual(roles); }); - it('replaces existing roles', () => { - setRoles([{ id: 1, name: 'admin', color: '#ff0000', permissions: 0 }]); - setRoles([{ id: 2, name: 'member', color: '#00ff00', permissions: 0 }]); + it("replaces existing roles", () => { + setRoles([{ id: 1, name: "admin", color: "#ff0000", permissions: 0 }]); + setRoles([{ id: 2, name: "member", color: "#00ff00", permissions: 0 }]); expect(channelsStore.getState().roles).toHaveLength(1); - expect(channelsStore.getState().roles[0]!.name).toBe('member'); + expect(channelsStore.getState().roles[0]!.name).toBe("member"); }); }); - describe('getRoleIdByName', () => { - it('returns role id for matching name (case-insensitive)', () => { + describe("getRoleIdByName", () => { + it("returns role id for matching name (case-insensitive)", () => { setRoles([ - { id: 1, name: 'Admin', color: '#ff0000', permissions: 0 }, - { id: 2, name: 'Member', color: '#00ff00', permissions: 0 }, + { id: 1, name: "Admin", color: "#ff0000", permissions: 0 }, + { id: 2, name: "Member", color: "#00ff00", permissions: 0 }, ]); - expect(getRoleIdByName('admin')).toBe(1); - expect(getRoleIdByName('ADMIN')).toBe(1); - expect(getRoleIdByName('member')).toBe(2); + expect(getRoleIdByName("admin")).toBe(1); + expect(getRoleIdByName("ADMIN")).toBe(1); + expect(getRoleIdByName("member")).toBe(2); }); - it('returns undefined for non-existent role', () => { - setRoles([{ id: 1, name: 'admin', color: '#ff0000', permissions: 0 }]); - expect(getRoleIdByName('moderator')).toBeUndefined(); + it("returns undefined for non-existent role", () => { + setRoles([{ id: 1, name: "admin", color: "#ff0000", permissions: 0 }]); + expect(getRoleIdByName("moderator")).toBeUndefined(); }); - it('returns undefined when no roles set', () => { - expect(getRoleIdByName('admin')).toBeUndefined(); + it("returns undefined when no roles set", () => { + expect(getRoleIdByName("admin")).toBeUndefined(); }); }); - describe('updateChannelPosition', () => { - it('updates a channel position', () => { + describe("updateChannelPosition", () => { + it("updates a channel position", () => { setChannels(readyChannels); updateChannelPosition(1, 5); @@ -414,7 +424,7 @@ describe('channels store', () => { expect(channelsStore.getState().channels.get(1)?.position).toBe(5); }); - it('is a no-op for unknown channel id', () => { + it("is a no-op for unknown channel id", () => { setChannels(readyChannels); const before = channelsStore.getState(); @@ -423,7 +433,7 @@ describe('channels store', () => { expect(channelsStore.getState()).toBe(before); }); - it('is a no-op when position is already the same', () => { + it("is a no-op when position is already the same", () => { setChannels(readyChannels); const before = channelsStore.getState(); @@ -432,7 +442,7 @@ describe('channels store', () => { expect(channelsStore.getState()).toBe(before); }); - it('produces a new channel object (immutable)', () => { + it("produces a new channel object (immutable)", () => { setChannels(readyChannels); const before = channelsStore.getState().channels.get(1); @@ -444,56 +454,56 @@ describe('channels store', () => { }); }); - describe('getChannelsByCategory — DM filtering', () => { - it('excludes DM channels from category grouping', () => { + describe("getChannelsByCategory — DM filtering", () => { + it("excludes DM channels from category grouping", () => { setChannels([ - { id: 1, name: 'general', type: 'text', category: 'Text', position: 0 }, - { id: 2, name: 'dm-channel', type: 'dm', category: null, position: 0 }, + { id: 1, name: "general", type: "text", category: "Text", position: 0 }, + { id: 2, name: "dm-channel", type: "dm", category: null, position: 0 }, ]); const grouped = getChannelsByCategory(); // DM channels should be filtered out expect(grouped.size).toBe(1); - expect(grouped.has('Text')).toBe(true); + expect(grouped.has("Text")).toBe(true); // Verify the DM is not in any group for (const channels of grouped.values()) { - expect(channels.every((ch) => ch.type !== 'dm')).toBe(true); + expect(channels.every((ch) => ch.type !== "dm")).toBe(true); } }); }); - describe('getChannelsByCategory — sort order', () => { - it('sorts channels within same category by position', () => { + describe("getChannelsByCategory — sort order", () => { + it("sorts channels within same category by position", () => { setChannels([ - { id: 1, name: 'c-channel', type: 'text', category: 'Text', position: 2 }, - { id: 2, name: 'a-channel', type: 'text', category: 'Text', position: 0 }, - { id: 3, name: 'b-channel', type: 'text', category: 'Text', position: 1 }, + { id: 1, name: "c-channel", type: "text", category: "Text", position: 2 }, + { id: 2, name: "a-channel", type: "text", category: "Text", position: 0 }, + { id: 3, name: "b-channel", type: "text", category: "Text", position: 1 }, ]); const grouped = getChannelsByCategory(); - const textChannels = grouped.get('Text')!; - expect(textChannels[0]!.name).toBe('a-channel'); - expect(textChannels[1]!.name).toBe('b-channel'); - expect(textChannels[2]!.name).toBe('c-channel'); + const textChannels = grouped.get("Text")!; + expect(textChannels[0]!.name).toBe("a-channel"); + expect(textChannels[1]!.name).toBe("b-channel"); + expect(textChannels[2]!.name).toBe("c-channel"); }); }); - describe('setActiveChannel — edge case: unknown channel with unread=0', () => { - it('sets activeChannelId even for a channel not in the map', () => { + describe("setActiveChannel — edge case: unknown channel with unread=0", () => { + it("sets activeChannelId even for a channel not in the map", () => { setActiveChannel(999); expect(channelsStore.getState().activeChannelId).toBe(999); }); }); - describe('updateChannel — no changes', () => { - it('still creates new object when neither name nor position is provided', () => { + describe("updateChannel — no changes", () => { + it("still creates new object when neither name nor position is provided", () => { setChannels(readyChannels); updateChannel({ id: 1 } as ChannelUpdatePayload); // Channel should still exist with original values const ch = channelsStore.getState().channels.get(1); - expect(ch?.name).toBe('general'); + expect(ch?.name).toBe("general"); expect(ch?.position).toBe(0); }); }); diff --git a/Client/tauri-client/tests/unit/chat-area.test.ts b/Client/tauri-client/tests/unit/chat-area.test.ts index 4fafc1b6..83d62c57 100644 --- a/Client/tauri-client/tests/unit/chat-area.test.ts +++ b/Client/tauri-client/tests/unit/chat-area.test.ts @@ -26,7 +26,10 @@ vi.mock("@lib/icons", () => ({ vi.mock("@lib/logger", () => ({ createLogger: () => ({ - debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), }), })); @@ -65,7 +68,10 @@ vi.mock("@components/VideoGrid", () => ({ import { createChatArea } from "../../src/pages/main-page/ChatArea"; import type { ChatAreaOptions } from "../../src/pages/main-page/ChatArea"; -import { createPinnedPanelController, createSearchOverlayController } from "../../src/pages/main-page/OverlayManagers"; +import { + createPinnedPanelController, + createSearchOverlayController, +} from "../../src/pages/main-page/OverlayManagers"; // --------------------------------------------------------------------------- // Helpers @@ -73,7 +79,11 @@ import { createPinnedPanelController, createSearchOverlayController } from "../. function makeOptions(overrides: Partial = {}): ChatAreaOptions { return { - api: { getPins: vi.fn(), search: vi.fn(), unpinMessage: vi.fn() } as unknown as ChatAreaOptions["api"], + api: { + getPins: vi.fn(), + search: vi.fn(), + unpinMessage: vi.fn(), + } as unknown as ChatAreaOptions["api"], getRoot: () => document.createElement("div"), getToast: () => null, getChannelCtrl: () => null, @@ -306,7 +316,9 @@ describe("createChatArea", () => { it("focusing search input opens the search overlay controller", () => { const result = createChatArea(makeOptions()); - const searchInput = result.chatArea.querySelector("[data-testid='search-input']") as HTMLInputElement; + const searchInput = result.chatArea.querySelector( + "[data-testid='search-input']", + ) as HTMLInputElement; expect(searchInput).not.toBeNull(); searchInput.dispatchEvent(new Event("focus")); expect(mockSearchOpen).toHaveBeenCalledTimes(1); diff --git a/Client/tauri-client/tests/unit/chat-header.test.ts b/Client/tauri-client/tests/unit/chat-header.test.ts index 51c6048f..62754fcc 100644 --- a/Client/tauri-client/tests/unit/chat-header.test.ts +++ b/Client/tauri-client/tests/unit/chat-header.test.ts @@ -29,7 +29,9 @@ describe("ChatHeader", () => { container.appendChild(element); expect(refs.nameEl.textContent).toBe("general"); - expect(container.querySelector('[data-testid="chat-header-name"]')?.textContent).toBe("general"); + expect(container.querySelector('[data-testid="chat-header-name"]')?.textContent).toBe( + "general", + ); }); it("displays hash prefix", () => { @@ -73,7 +75,9 @@ describe("ChatHeader", () => { // Update name via ref refs.nameEl.textContent = "announcements"; - expect(container.querySelector('[data-testid="chat-header-name"]')?.textContent).toBe("announcements"); + expect(container.querySelector('[data-testid="chat-header-name"]')?.textContent).toBe( + "announcements", + ); // Update topic via ref refs.topicEl.textContent = "Important news"; diff --git a/Client/tauri-client/tests/unit/chat.test.ts b/Client/tauri-client/tests/unit/chat.test.ts index a8b88b7f..676ca99b 100644 --- a/Client/tauri-client/tests/unit/chat.test.ts +++ b/Client/tauri-client/tests/unit/chat.test.ts @@ -9,9 +9,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // jsdom does not provide ResizeObserver — stub it so MessageList can mount. if (typeof globalThis.ResizeObserver === "undefined") { globalThis.ResizeObserver = class { - observe(): void { /* noop */ } - unobserve(): void { /* noop */ } - disconnect(): void { /* noop */ } + observe(): void { + /* noop */ + } + unobserve(): void { + /* noop */ + } + disconnect(): void { + /* noop */ + } } as unknown as typeof ResizeObserver; } @@ -20,11 +26,7 @@ if (typeof globalThis.ResizeObserver === "undefined") { // --------------------------------------------------------------------------- import { createMessageList } from "../../src/components/MessageList"; -import { - messagesStore, - addMessage, - setMessages, -} from "../../src/stores/messages.store"; +import { messagesStore, addMessage, setMessages } from "../../src/stores/messages.store"; import { membersStore, setMembers } from "../../src/stores/members.store"; // Reset stores before each test @@ -105,14 +107,14 @@ describe("MessageList", () => { expect(messagesContainer).not.toBeNull(); const welcome = messagesContainer?.querySelector(".channel-welcome"); expect(welcome).not.toBeNull(); - expect(welcome?.querySelector(".channel-welcome-title")?.textContent).toBe("Welcome to #general!"); + expect(welcome?.querySelector(".channel-welcome-title")?.textContent).toBe( + "Welcome to #general!", + ); list.destroy?.(); }); it("renders messages after store update", () => { - setMessages(1, [ - makeMessage(1, 10, "Alice", "Hello", "2026-03-15T10:00:00Z"), - ], false); + setMessages(1, [makeMessage(1, 10, "Alice", "Hello", "2026-03-15T10:00:00Z")], false); const list = createMessageList({ channelId: 1, @@ -136,11 +138,15 @@ describe("MessageList", () => { describe("message grouping", () => { it("groups consecutive messages from same user within 5 minutes", () => { - setMessages(1, [ - makeMessage(1, 10, "Alice", "Hi", "2026-03-15T10:00:00Z"), - makeMessage(2, 10, "Alice", "How are you?", "2026-03-15T10:02:00Z"), - makeMessage(3, 10, "Alice", "Anyone there?", "2026-03-15T10:04:00Z"), - ], false); + setMessages( + 1, + [ + makeMessage(1, 10, "Alice", "Hi", "2026-03-15T10:00:00Z"), + makeMessage(2, 10, "Alice", "How are you?", "2026-03-15T10:02:00Z"), + makeMessage(3, 10, "Alice", "Anyone there?", "2026-03-15T10:04:00Z"), + ], + false, + ); const list = createMessageList({ channelId: 1, @@ -164,10 +170,14 @@ describe("MessageList", () => { }); it("breaks group when user changes", () => { - setMessages(1, [ - makeMessage(1, 10, "Alice", "Hi", "2026-03-15T10:00:00Z"), - makeMessage(2, 20, "Bob", "Hey!", "2026-03-15T10:01:00Z"), - ], false); + setMessages( + 1, + [ + makeMessage(1, 10, "Alice", "Hi", "2026-03-15T10:00:00Z"), + makeMessage(2, 20, "Bob", "Hey!", "2026-03-15T10:01:00Z"), + ], + false, + ); const list = createMessageList({ channelId: 1, @@ -188,10 +198,14 @@ describe("MessageList", () => { }); it("breaks group when gap exceeds 5 minutes", () => { - setMessages(1, [ - makeMessage(1, 10, "Alice", "Hi", "2026-03-15T10:00:00Z"), - makeMessage(2, 10, "Alice", "Later", "2026-03-15T10:10:00Z"), - ], false); + setMessages( + 1, + [ + makeMessage(1, 10, "Alice", "Hi", "2026-03-15T10:00:00Z"), + makeMessage(2, 10, "Alice", "Later", "2026-03-15T10:10:00Z"), + ], + false, + ); const list = createMessageList({ channelId: 1, @@ -214,10 +228,14 @@ describe("MessageList", () => { describe("day dividers", () => { it("inserts day divider between messages on different days", () => { - setMessages(1, [ - makeMessage(1, 10, "Alice", "Day 1", "2026-03-10T12:00:00Z"), - makeMessage(2, 10, "Alice", "Day 2", "2026-03-15T12:00:00Z"), - ], false); + setMessages( + 1, + [ + makeMessage(1, 10, "Alice", "Day 1", "2026-03-10T12:00:00Z"), + makeMessage(2, 10, "Alice", "Day 2", "2026-03-15T12:00:00Z"), + ], + false, + ); const list = createMessageList({ channelId: 1, @@ -240,9 +258,11 @@ describe("MessageList", () => { describe("@mention parsing", () => { it("wraps @username in .mention span", () => { - setMessages(1, [ - makeMessage(1, 10, "Alice", "Hey @Bob check this", "2026-03-15T10:00:00Z"), - ], false); + setMessages( + 1, + [makeMessage(1, 10, "Alice", "Hey @Bob check this", "2026-03-15T10:00:00Z")], + false, + ); const list = createMessageList({ channelId: 1, @@ -264,9 +284,11 @@ describe("MessageList", () => { }); it("handles multiple @mentions in one message", () => { - setMessages(1, [ - makeMessage(1, 10, "Alice", "@Bob and @Charlie look", "2026-03-15T10:00:00Z"), - ], false); + setMessages( + 1, + [makeMessage(1, 10, "Alice", "@Bob and @Charlie look", "2026-03-15T10:00:00Z")], + false, + ); const list = createMessageList({ channelId: 1, @@ -289,9 +311,11 @@ describe("MessageList", () => { describe("deleted and edited messages", () => { it("shows [message deleted] for deleted messages", () => { - setMessages(1, [ - makeMessage(1, 10, "Alice", "secret", "2026-03-15T10:00:00Z", { deleted: true }), - ], false); + setMessages( + 1, + [makeMessage(1, 10, "Alice", "secret", "2026-03-15T10:00:00Z", { deleted: true })], + false, + ); const list = createMessageList({ channelId: 1, @@ -312,11 +336,15 @@ describe("MessageList", () => { }); it("shows (edited) indicator for edited messages", () => { - setMessages(1, [ - makeMessage(1, 10, "Alice", "updated text", "2026-03-15T10:00:00Z", { - editedAt: "2026-03-15T10:05:00Z", - }), - ], false); + setMessages( + 1, + [ + makeMessage(1, 10, "Alice", "updated text", "2026-03-15T10:00:00Z", { + editedAt: "2026-03-15T10:05:00Z", + }), + ], + false, + ); const list = createMessageList({ channelId: 1, @@ -339,9 +367,7 @@ describe("MessageList", () => { describe("system messages", () => { it("applies msg--system class to System user messages", () => { - setMessages(1, [ - makeMessage(1, 0, "System", "Alice joined", "2026-03-15T10:00:00Z"), - ], false); + setMessages(1, [makeMessage(1, 0, "System", "Alice joined", "2026-03-15T10:00:00Z")], false); const list = createMessageList({ channelId: 1, diff --git a/Client/tauri-client/tests/unit/connect-page.test.ts b/Client/tauri-client/tests/unit/connect-page.test.ts index 035ba5cc..8353661f 100644 --- a/Client/tauri-client/tests/unit/connect-page.test.ts +++ b/Client/tauri-client/tests/unit/connect-page.test.ts @@ -38,9 +38,7 @@ function makeCallbacks(overrides: Partial = {}): ConnectPa }; } -const testProfiles: SimpleProfile[] = [ - { name: "Test Server", host: "localhost:8443" }, -]; +const testProfiles: SimpleProfile[] = [{ name: "Test Server", host: "localhost:8443" }]; describe("ConnectPage", () => { let container: HTMLDivElement; @@ -228,7 +226,9 @@ describe("ConnectPage", () => { it("disables form inputs during loading state", async () => { let resolveLogin: () => void; - const loginPromise = new Promise((resolve) => { resolveLogin = resolve; }); + const loginPromise = new Promise((resolve) => { + resolveLogin = resolve; + }); const onLogin = vi.fn().mockReturnValue(loginPromise); const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles); @@ -269,7 +269,11 @@ describe("ConnectPage", () => { // --- selectServer / auto-login flow --- it("selectServer sets host and loads credentials asynchronously", async () => { - mockLoadCredential.mockResolvedValue({ username: "saveduser", token: "tok", password: "savedpass" }); + mockLoadCredential.mockResolvedValue({ + username: "saveduser", + token: "tok", + password: "savedpass", + }); const page = createConnectPage(makeCallbacks(), testProfiles); page.mount(container); @@ -284,8 +288,9 @@ describe("ConnectPage", () => { expect(usernameInput.value).toBe("saveduser"); }); + // Password is no longer returned from credential store over IPC (security hardening) const passwordInput = container.querySelector("#password") as HTMLInputElement; - expect(passwordInput.value).toBe("savedpass"); + expect(passwordInput.value).toBe(""); page.destroy?.(); }); @@ -627,7 +632,12 @@ describe("ConnectPage", () => { form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); await vi.waitFor(() => { - expect(onRegister).toHaveBeenCalledWith("localhost:8443", "newuser", "password123", "INVITE-CODE"); + expect(onRegister).toHaveBeenCalledWith( + "localhost:8443", + "newuser", + "password123", + "INVITE-CODE", + ); }); page.destroy?.(); @@ -782,7 +792,9 @@ describe("ConnectPage", () => { it("ignores submit while already loading", async () => { let resolveLogin: () => void; - const loginPromise = new Promise((resolve) => { resolveLogin = resolve; }); + const loginPromise = new Promise((resolve) => { + resolveLogin = resolve; + }); const onLogin = vi.fn().mockReturnValue(loginPromise); const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles); @@ -799,7 +811,9 @@ describe("ConnectPage", () => { const form = container.querySelector(".connect-form") as HTMLFormElement; form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); - await vi.waitFor(() => { expect(onLogin).toHaveBeenCalledTimes(1); }); + await vi.waitFor(() => { + expect(onLogin).toHaveBeenCalledTimes(1); + }); // Submit again while loading form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); @@ -945,7 +959,9 @@ describe("ConnectPage", () => { it("shows correct button text during loading states", async () => { let resolveLogin: () => void; - const loginPromise = new Promise((resolve) => { resolveLogin = resolve; }); + const loginPromise = new Promise((resolve) => { + resolveLogin = resolve; + }); const onLogin = vi.fn().mockReturnValue(loginPromise); const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles); @@ -973,7 +989,9 @@ describe("ConnectPage", () => { it("shows registering text in register mode during loading", async () => { let resolveRegister: () => void; - const registerPromise = new Promise((resolve) => { resolveRegister = resolve; }); + const registerPromise = new Promise((resolve) => { + resolveRegister = resolve; + }); const onRegister = vi.fn().mockReturnValue(registerPromise); const page = createConnectPage(makeCallbacks({ onRegister }), testProfiles); diff --git a/Client/tauri-client/tests/unit/connection-stats.test.ts b/Client/tauri-client/tests/unit/connection-stats.test.ts index 8954a62a..0a190f3b 100644 --- a/Client/tauri-client/tests/unit/connection-stats.test.ts +++ b/Client/tauri-client/tests/unit/connection-stats.test.ts @@ -203,7 +203,13 @@ describe("createConnectionStatsPoller", () => { it("classifies quality as fair for RTT 100-200ms", async () => { const room = createMockRoom([ - { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.15, bytesSent: 0, bytesReceived: 0 }, + { + id: "cp1", + type: "candidate-pair", + currentRoundTripTime: 0.15, + bytesSent: 0, + bytesReceived: 0, + }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); @@ -217,7 +223,13 @@ describe("createConnectionStatsPoller", () => { it("classifies quality as poor for RTT 200-400ms", async () => { const room = createMockRoom([ - { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.3, bytesSent: 0, bytesReceived: 0 }, + { + id: "cp1", + type: "candidate-pair", + currentRoundTripTime: 0.3, + bytesSent: 0, + bytesReceived: 0, + }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); @@ -231,7 +243,13 @@ describe("createConnectionStatsPoller", () => { it("classifies quality as bad for RTT >= 400ms", async () => { const room = createMockRoom([ - { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.5, bytesSent: 0, bytesReceived: 0 }, + { + id: "cp1", + type: "candidate-pair", + currentRoundTripTime: 0.5, + bytesSent: 0, + bytesReceived: 0, + }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); @@ -245,7 +263,13 @@ describe("createConnectionStatsPoller", () => { it("extracts outbound-rtp and inbound-rtp packet counts", async () => { const room = createMockRoom([ - { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.01, bytesSent: 100, bytesReceived: 200 }, + { + id: "cp1", + type: "candidate-pair", + currentRoundTripTime: 0.01, + bytesSent: 100, + bytesReceived: 200, + }, { id: "out1", type: "outbound-rtp", packetsSent: 500, bytesSent: 40000 }, { id: "in1", type: "inbound-rtp", packetsReceived: 300, bytesReceived: 30000 }, ]); @@ -265,7 +289,13 @@ describe("createConnectionStatsPoller", () => { const room = createMockRoom([ { id: "out1", type: "outbound-rtp", packetsSent: 100, bytesSent: 10000 }, { id: "in1", type: "inbound-rtp", packetsReceived: 50, bytesReceived: 5000 }, - { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.01, bytesSent: 0, bytesReceived: 0 }, + { + id: "cp1", + type: "candidate-pair", + currentRoundTripTime: 0.01, + bytesSent: 0, + bytesReceived: 0, + }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); @@ -307,7 +337,7 @@ describe("createConnectionStatsPoller", () => { }; const qualityCb = vi.fn(); - poller = createConnectionStatsPoller(() => roomActive ? room as any : null); + poller = createConnectionStatsPoller(() => (roomActive ? (room as any) : null)); poller.onQualityChanged(qualityCb); poller.start(); @@ -330,7 +360,13 @@ describe("createConnectionStatsPoller", () => { it("unsubscribed onUpdate callback is not called", async () => { const room = createMockRoom([ - { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.01, bytesSent: 0, bytesReceived: 0 }, + { + id: "cp1", + type: "candidate-pair", + currentRoundTripTime: 0.01, + bytesSent: 0, + bytesReceived: 0, + }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); @@ -468,8 +504,20 @@ describe("createConnectionStatsPoller", () => { it("ignores candidate-pair entries with non-numeric or zero RTT", async () => { const room = createMockRoom([ - { id: "cp1", type: "candidate-pair", currentRoundTripTime: "bad", bytesSent: 0, bytesReceived: 0 }, - { id: "cp2", type: "candidate-pair", currentRoundTripTime: 0, bytesSent: 0, bytesReceived: 0 }, + { + id: "cp1", + type: "candidate-pair", + currentRoundTripTime: "bad", + bytesSent: 0, + bytesReceived: 0, + }, + { + id: "cp2", + type: "candidate-pair", + currentRoundTripTime: 0, + bytesSent: 0, + bytesReceived: 0, + }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); @@ -483,8 +531,20 @@ describe("createConnectionStatsPoller", () => { it("picks the lowest RTT when multiple candidate-pairs exist", async () => { const room = createMockRoom([ - { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.3, bytesSent: 0, bytesReceived: 0 }, - { id: "cp2", type: "candidate-pair", currentRoundTripTime: 0.05, bytesSent: 0, bytesReceived: 0 }, + { + id: "cp1", + type: "candidate-pair", + currentRoundTripTime: 0.3, + bytesSent: 0, + bytesReceived: 0, + }, + { + id: "cp2", + type: "candidate-pair", + currentRoundTripTime: 0.05, + bytesSent: 0, + bytesReceived: 0, + }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); @@ -605,7 +665,13 @@ describe("createConnectionStatsPoller", () => { it("handles outbound-rtp without packetsSent", async () => { const room = createMockRoom([ - { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.01, bytesSent: 0, bytesReceived: 0 }, + { + id: "cp1", + type: "candidate-pair", + currentRoundTripTime: 0.01, + bytesSent: 0, + bytesReceived: 0, + }, { id: "out1", type: "outbound-rtp", bytesSent: 100 }, ]); const cb = vi.fn(); @@ -619,7 +685,13 @@ describe("createConnectionStatsPoller", () => { it("handles inbound-rtp without packetsReceived", async () => { const room = createMockRoom([ - { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.01, bytesSent: 0, bytesReceived: 0 }, + { + id: "cp1", + type: "candidate-pair", + currentRoundTripTime: 0.01, + bytesSent: 0, + bytesReceived: 0, + }, { id: "in1", type: "inbound-rtp", bytesReceived: 100 }, ]); const cb = vi.fn(); diff --git a/Client/tauri-client/tests/unit/context-menu.test.ts b/Client/tauri-client/tests/unit/context-menu.test.ts index fc6fa9c5..7c7c7be4 100644 --- a/Client/tauri-client/tests/unit/context-menu.test.ts +++ b/Client/tauri-client/tests/unit/context-menu.test.ts @@ -1,97 +1,97 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { showContextMenu } from '../../src/lib/context-menu'; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { showContextMenu } from "../../src/lib/context-menu"; -describe('showContextMenu', () => { +describe("showContextMenu", () => { let ac: AbortController; beforeEach(() => { ac = new AbortController(); // Clean up any leftover menus - document.querySelectorAll('.context-menu').forEach((el) => el.remove()); + document.querySelectorAll(".context-menu").forEach((el) => el.remove()); }); afterEach(() => { ac.abort(); - document.querySelectorAll('.context-menu').forEach((el) => el.remove()); + document.querySelectorAll(".context-menu").forEach((el) => el.remove()); }); - it('renders menu at correct position', () => { + it("renders menu at correct position", () => { showContextMenu({ x: 100, y: 200, - items: [{ label: 'Test', onClick: vi.fn() }], + items: [{ label: "Test", onClick: vi.fn() }], signal: ac.signal, }); - const menu = document.querySelector('.context-menu') as HTMLElement; + const menu = document.querySelector(".context-menu") as HTMLElement; expect(menu).not.toBeNull(); - expect(menu.style.left).toBe('100px'); - expect(menu.style.top).toBe('200px'); + expect(menu.style.left).toBe("100px"); + expect(menu.style.top).toBe("200px"); }); - it('renders all items', () => { + it("renders all items", () => { showContextMenu({ x: 0, y: 0, items: [ - { label: 'Edit', onClick: vi.fn() }, - { label: 'Delete', onClick: vi.fn(), danger: true }, + { label: "Edit", onClick: vi.fn() }, + { label: "Delete", onClick: vi.fn(), danger: true }, ], signal: ac.signal, }); - const items = document.querySelectorAll('.context-menu-item'); + const items = document.querySelectorAll(".context-menu-item"); expect(items.length).toBe(2); - expect(items[0]!.textContent).toBe('Edit'); - expect(items[1]!.textContent).toBe('Delete'); + expect(items[0]!.textContent).toBe("Edit"); + expect(items[1]!.textContent).toBe("Delete"); }); - it('fires onClick when item clicked', () => { + it("fires onClick when item clicked", () => { const onClick = vi.fn(); showContextMenu({ x: 0, y: 0, - items: [{ label: 'Action', onClick }], + items: [{ label: "Action", onClick }], signal: ac.signal, }); - const item = document.querySelector('.context-menu-item') as HTMLElement; + const item = document.querySelector(".context-menu-item") as HTMLElement; item.click(); expect(onClick).toHaveBeenCalledTimes(1); }); - it('removes menu after item click', () => { + it("removes menu after item click", () => { showContextMenu({ x: 0, y: 0, - items: [{ label: 'Action', onClick: vi.fn() }], + items: [{ label: "Action", onClick: vi.fn() }], signal: ac.signal, }); - const item = document.querySelector('.context-menu-item') as HTMLElement; + const item = document.querySelector(".context-menu-item") as HTMLElement; item.click(); - expect(document.querySelector('.context-menu')).toBeNull(); + expect(document.querySelector(".context-menu")).toBeNull(); }); - it('applies danger class to danger items', () => { + it("applies danger class to danger items", () => { showContextMenu({ x: 0, y: 0, - items: [{ label: 'Delete', onClick: vi.fn(), danger: true }], + items: [{ label: "Delete", onClick: vi.fn(), danger: true }], signal: ac.signal, }); - const item = document.querySelector('.context-menu-item') as HTMLElement; - expect(item.classList.contains('danger')).toBe(true); + const item = document.querySelector(".context-menu-item") as HTMLElement; + expect(item.classList.contains("danger")).toBe(true); }); - it('applies testId to items', () => { + it("applies testId to items", () => { showContextMenu({ x: 0, y: 0, - items: [{ label: 'Edit', onClick: vi.fn(), testId: 'ctx-edit' }], + items: [{ label: "Edit", onClick: vi.fn(), testId: "ctx-edit" }], signal: ac.signal, }); @@ -99,162 +99,160 @@ describe('showContextMenu', () => { expect(item).not.toBeNull(); }); - it('removes menu on AbortSignal abort', () => { + it("removes menu on AbortSignal abort", () => { showContextMenu({ x: 0, y: 0, - items: [{ label: 'Test', onClick: vi.fn() }], + items: [{ label: "Test", onClick: vi.fn() }], signal: ac.signal, }); - expect(document.querySelector('.context-menu')).not.toBeNull(); + expect(document.querySelector(".context-menu")).not.toBeNull(); ac.abort(); - expect(document.querySelector('.context-menu')).toBeNull(); + expect(document.querySelector(".context-menu")).toBeNull(); }); - it('removes existing menu with same className before showing new one', () => { + it("removes existing menu with same className before showing new one", () => { showContextMenu({ x: 0, y: 0, - items: [{ label: 'First', onClick: vi.fn() }], + items: [{ label: "First", onClick: vi.fn() }], signal: ac.signal, - className: 'my-menu', + className: "my-menu", }); showContextMenu({ x: 50, y: 50, - items: [{ label: 'Second', onClick: vi.fn() }], + items: [{ label: "Second", onClick: vi.fn() }], signal: ac.signal, - className: 'my-menu', + className: "my-menu", }); - const menus = document.querySelectorAll('.my-menu'); + const menus = document.querySelectorAll(".my-menu"); expect(menus.length).toBe(1); - expect(menus[0]!.querySelector('.context-menu-item')!.textContent).toBe('Second'); + expect(menus[0]!.querySelector(".context-menu-item")!.textContent).toBe("Second"); }); it('uses default className "context-menu" when none specified', () => { showContextMenu({ x: 10, y: 20, - items: [{ label: 'Default', onClick: vi.fn() }], + items: [{ label: "Default", onClick: vi.fn() }], signal: ac.signal, }); - const menu = document.querySelector('.context-menu') as HTMLElement; + const menu = document.querySelector(".context-menu") as HTMLElement; expect(menu).not.toBeNull(); - expect(menu.classList.contains('context-menu')).toBe(true); + expect(menu.classList.contains("context-menu")).toBe(true); }); - it('adds separator before danger item when preceded by non-danger item', () => { + it("adds separator before danger item when preceded by non-danger item", () => { showContextMenu({ x: 0, y: 0, items: [ - { label: 'Edit', onClick: vi.fn() }, - { label: 'Delete', onClick: vi.fn(), danger: true }, + { label: "Edit", onClick: vi.fn() }, + { label: "Delete", onClick: vi.fn(), danger: true }, ], signal: ac.signal, }); - const menu = document.querySelector('.context-menu') as HTMLElement; - const sep = menu.querySelector('.context-menu-sep'); + const menu = document.querySelector(".context-menu") as HTMLElement; + const sep = menu.querySelector(".context-menu-sep"); expect(sep).not.toBeNull(); }); - it('does not add separator when danger item is first', () => { + it("does not add separator when danger item is first", () => { showContextMenu({ x: 0, y: 0, - items: [ - { label: 'Delete', onClick: vi.fn(), danger: true }, - ], + items: [{ label: "Delete", onClick: vi.fn(), danger: true }], signal: ac.signal, }); - const menu = document.querySelector('.context-menu') as HTMLElement; - const sep = menu.querySelector('.context-menu-sep'); + const menu = document.querySelector(".context-menu") as HTMLElement; + const sep = menu.querySelector(".context-menu-sep"); expect(sep).toBeNull(); }); - it('does not add separator between consecutive danger items', () => { + it("does not add separator between consecutive danger items", () => { showContextMenu({ x: 0, y: 0, items: [ - { label: 'Delete', onClick: vi.fn(), danger: true }, - { label: 'Ban', onClick: vi.fn(), danger: true }, + { label: "Delete", onClick: vi.fn(), danger: true }, + { label: "Ban", onClick: vi.fn(), danger: true }, ], signal: ac.signal, }); - const menu = document.querySelector('.context-menu') as HTMLElement; - const seps = menu.querySelectorAll('.context-menu-sep'); + const menu = document.querySelector(".context-menu") as HTMLElement; + const seps = menu.querySelectorAll(".context-menu-sep"); expect(seps.length).toBe(0); }); - it('handles multiple non-danger items without separator', () => { + it("handles multiple non-danger items without separator", () => { showContextMenu({ x: 0, y: 0, items: [ - { label: 'Copy', onClick: vi.fn() }, - { label: 'Edit', onClick: vi.fn() }, - { label: 'Reply', onClick: vi.fn() }, + { label: "Copy", onClick: vi.fn() }, + { label: "Edit", onClick: vi.fn() }, + { label: "Reply", onClick: vi.fn() }, ], signal: ac.signal, }); - const menu = document.querySelector('.context-menu') as HTMLElement; - const seps = menu.querySelectorAll('.context-menu-sep'); + const menu = document.querySelector(".context-menu") as HTMLElement; + const seps = menu.querySelectorAll(".context-menu-sep"); expect(seps.length).toBe(0); }); - it('closes menu on click outside (mousedown)', async () => { + it("closes menu on click outside (mousedown)", async () => { showContextMenu({ x: 0, y: 0, - items: [{ label: 'Test', onClick: vi.fn() }], + items: [{ label: "Test", onClick: vi.fn() }], signal: ac.signal, }); - expect(document.querySelector('.context-menu')).not.toBeNull(); + expect(document.querySelector(".context-menu")).not.toBeNull(); // Trigger the deferred mousedown listener (needs setTimeout to fire first) await vi.waitFor(() => { // Simulate a click outside the menu - document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); - expect(document.querySelector('.context-menu')).toBeNull(); + document.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + expect(document.querySelector(".context-menu")).toBeNull(); }); }); - it('does not close menu on mousedown inside the menu', async () => { + it("does not close menu on mousedown inside the menu", async () => { showContextMenu({ x: 0, y: 0, - items: [{ label: 'Test', onClick: vi.fn() }], + items: [{ label: "Test", onClick: vi.fn() }], signal: ac.signal, }); - const menu = document.querySelector('.context-menu') as HTMLElement; + const menu = document.querySelector(".context-menu") as HTMLElement; expect(menu).not.toBeNull(); // Wait for the deferred listener to register await new Promise((r) => setTimeout(r, 10)); // Mousedown inside the menu should NOT close it - const item = menu.querySelector('.context-menu-item') as HTMLElement; - const event = new MouseEvent('mousedown', { bubbles: true }); - Object.defineProperty(event, 'target', { value: item }); + const item = menu.querySelector(".context-menu-item") as HTMLElement; + const event = new MouseEvent("mousedown", { bubbles: true }); + Object.defineProperty(event, "target", { value: item }); document.dispatchEvent(event); - expect(document.querySelector('.context-menu')).not.toBeNull(); + expect(document.querySelector(".context-menu")).not.toBeNull(); }); - it('handles empty items list', () => { + it("handles empty items list", () => { showContextMenu({ x: 0, y: 0, @@ -262,59 +260,59 @@ describe('showContextMenu', () => { signal: ac.signal, }); - const menu = document.querySelector('.context-menu') as HTMLElement; + const menu = document.querySelector(".context-menu") as HTMLElement; expect(menu).not.toBeNull(); - expect(menu.querySelectorAll('.context-menu-item').length).toBe(0); + expect(menu.querySelectorAll(".context-menu-item").length).toBe(0); }); - it('clicking one item does not affect other items', () => { + it("clicking one item does not affect other items", () => { const onClick1 = vi.fn(); const onClick2 = vi.fn(); showContextMenu({ x: 0, y: 0, items: [ - { label: 'Action1', onClick: onClick1 }, - { label: 'Action2', onClick: onClick2 }, + { label: "Action1", onClick: onClick1 }, + { label: "Action2", onClick: onClick2 }, ], signal: ac.signal, }); - const items = document.querySelectorAll('.context-menu-item'); + const items = document.querySelectorAll(".context-menu-item"); (items[0] as HTMLElement).click(); expect(onClick1).toHaveBeenCalledTimes(1); expect(onClick2).not.toHaveBeenCalled(); }); - it('handles non-danger item followed by danger item with separator', () => { + it("handles non-danger item followed by danger item with separator", () => { showContextMenu({ x: 0, y: 0, items: [ - { label: 'Copy', onClick: vi.fn() }, - { label: 'Edit', onClick: vi.fn() }, - { label: 'Delete', onClick: vi.fn(), danger: true }, + { label: "Copy", onClick: vi.fn() }, + { label: "Edit", onClick: vi.fn() }, + { label: "Delete", onClick: vi.fn(), danger: true }, ], signal: ac.signal, }); - const menu = document.querySelector('.context-menu') as HTMLElement; + const menu = document.querySelector(".context-menu") as HTMLElement; const children = Array.from(menu.children); // Should have: Copy, Edit, separator, Delete expect(children.length).toBe(4); - expect(children[2]!.classList.contains('context-menu-sep')).toBe(true); + expect(children[2]!.classList.contains("context-menu-sep")).toBe(true); }); - it('menu is appended to document.body', () => { + it("menu is appended to document.body", () => { showContextMenu({ x: 100, y: 200, - items: [{ label: 'Appended', onClick: vi.fn() }], + items: [{ label: "Appended", onClick: vi.fn() }], signal: ac.signal, }); - const menu = document.body.querySelector('.context-menu'); + const menu = document.body.querySelector(".context-menu"); expect(menu).not.toBeNull(); }); }); diff --git a/Client/tauri-client/tests/unit/create-channel-modal.test.ts b/Client/tauri-client/tests/unit/create-channel-modal.test.ts index e73628c6..8e079d0f 100644 --- a/Client/tauri-client/tests/unit/create-channel-modal.test.ts +++ b/Client/tauri-client/tests/unit/create-channel-modal.test.ts @@ -42,17 +42,11 @@ describe("allowedTypesForCategory", () => { }); it("returns text and announcement for text categories", () => { - expect(allowedTypesForCategory("Text Channels")).toEqual([ - "text", - "announcement", - ]); + expect(allowedTypesForCategory("Text Channels")).toEqual(["text", "announcement"]); }); it("returns text and announcement for 'Chat'", () => { - expect(allowedTypesForCategory("Chat")).toEqual([ - "text", - "announcement", - ]); + expect(allowedTypesForCategory("Chat")).toEqual(["text", "announcement"]); }); }); @@ -94,7 +88,9 @@ describe("CreateChannelModal", () => { it("shows only text and announcement types for text categories", () => { const { modal } = makeModal("Text Channels"); - const select = container.querySelector("[data-testid='channel-type-select']") as HTMLSelectElement; + const select = container.querySelector( + "[data-testid='channel-type-select']", + ) as HTMLSelectElement; const options = Array.from(select.options).map((o) => o.value); expect(options).toEqual(["text", "announcement"]); expect(options).not.toContain("voice"); @@ -103,7 +99,9 @@ describe("CreateChannelModal", () => { it("shows only voice type for voice categories", () => { const { modal } = makeModal("Voice Channels"); - const select = container.querySelector("[data-testid='channel-type-select']") as HTMLSelectElement; + const select = container.querySelector( + "[data-testid='channel-type-select']", + ) as HTMLSelectElement; const options = Array.from(select.options).map((o) => o.value); expect(options).toEqual(["voice"]); expect(options).not.toContain("text"); @@ -121,7 +119,9 @@ describe("CreateChannelModal", () => { const onCreate = vi.fn(async () => {}); const { modal } = makeModal("Text Channels", { onCreate }); - const submitBtn = container.querySelector("[data-testid='channel-create-submit']") as HTMLButtonElement; + const submitBtn = container.querySelector( + "[data-testid='channel-create-submit']", + ) as HTMLButtonElement; submitBtn.click(); const error = container.querySelector("[data-testid='channel-create-error']"); @@ -134,10 +134,14 @@ describe("CreateChannelModal", () => { const onCreate = vi.fn(async () => {}); const { modal } = makeModal("Text Channels", { onCreate }); - const nameInput = container.querySelector("[data-testid='channel-name-input']") as HTMLInputElement; + const nameInput = container.querySelector( + "[data-testid='channel-name-input']", + ) as HTMLInputElement; nameInput.value = "test-channel"; - const submitBtn = container.querySelector("[data-testid='channel-create-submit']") as HTMLButtonElement; + const submitBtn = container.querySelector( + "[data-testid='channel-create-submit']", + ) as HTMLButtonElement; submitBtn.click(); // Wait for async handler @@ -174,10 +178,14 @@ describe("CreateChannelModal", () => { const onCreate = vi.fn().mockRejectedValue(new Error("Name already exists")); const { modal } = makeModal("Text Channels", { onCreate }); - const nameInput = container.querySelector("[data-testid='channel-name-input']") as HTMLInputElement; + const nameInput = container.querySelector( + "[data-testid='channel-name-input']", + ) as HTMLInputElement; nameInput.value = "duplicate"; - const submitBtn = container.querySelector("[data-testid='channel-create-submit']") as HTMLButtonElement; + const submitBtn = container.querySelector( + "[data-testid='channel-create-submit']", + ) as HTMLButtonElement; submitBtn.click(); await vi.waitFor(() => { @@ -196,10 +204,14 @@ describe("CreateChannelModal", () => { const onCreate = vi.fn().mockRejectedValue("string error"); const { modal } = makeModal("Text Channels", { onCreate }); - const nameInput = container.querySelector("[data-testid='channel-name-input']") as HTMLInputElement; + const nameInput = container.querySelector( + "[data-testid='channel-name-input']", + ) as HTMLInputElement; nameInput.value = "test"; - const submitBtn = container.querySelector("[data-testid='channel-create-submit']") as HTMLButtonElement; + const submitBtn = container.querySelector( + "[data-testid='channel-create-submit']", + ) as HTMLButtonElement; submitBtn.click(); await vi.waitFor(() => { @@ -212,13 +224,22 @@ describe("CreateChannelModal", () => { it("disables submit button and shows 'Creating...' while creating", async () => { let resolveCreate: (() => void) | undefined; - const onCreate = vi.fn(() => new Promise((resolve) => { resolveCreate = resolve; })); + const onCreate = vi.fn( + () => + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); const { modal } = makeModal("Text Channels", { onCreate }); - const nameInput = container.querySelector("[data-testid='channel-name-input']") as HTMLInputElement; + const nameInput = container.querySelector( + "[data-testid='channel-name-input']", + ) as HTMLInputElement; nameInput.value = "new-channel"; - const submitBtn = container.querySelector("[data-testid='channel-create-submit']") as HTMLButtonElement; + const submitBtn = container.querySelector( + "[data-testid='channel-create-submit']", + ) as HTMLButtonElement; submitBtn.click(); expect(submitBtn.hasAttribute("disabled")).toBe(true); @@ -243,7 +264,9 @@ describe("CreateChannelModal", () => { const onClose = vi.fn(); const { modal } = makeModal("Text Channels", { onClose }); - const overlay = container.querySelector("[data-testid='create-channel-modal']") as HTMLDivElement; + const overlay = container.querySelector( + "[data-testid='create-channel-modal']", + ) as HTMLDivElement; // Simulate clicking the overlay backdrop directly overlay.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -252,19 +275,26 @@ describe("CreateChannelModal", () => { }); it("clears previous error when submitting valid data after error", async () => { - const onCreate = vi.fn() + const onCreate = vi + .fn() .mockRejectedValueOnce(new Error("First error")) .mockResolvedValueOnce(undefined); const { modal } = makeModal("Text Channels", { onCreate }); - const nameInput = container.querySelector("[data-testid='channel-name-input']") as HTMLInputElement; + const nameInput = container.querySelector( + "[data-testid='channel-name-input']", + ) as HTMLInputElement; nameInput.value = "test"; - const submitBtn = container.querySelector("[data-testid='channel-create-submit']") as HTMLButtonElement; + const submitBtn = container.querySelector( + "[data-testid='channel-create-submit']", + ) as HTMLButtonElement; submitBtn.click(); await vi.waitFor(() => { - expect(container.querySelector("[data-testid='channel-create-error']")?.textContent).toBe("First error"); + expect(container.querySelector("[data-testid='channel-create-error']")?.textContent).toBe( + "First error", + ); }); // Try again with a valid name diff --git a/Client/tauri-client/tests/unit/delete-channel-modal.test.ts b/Client/tauri-client/tests/unit/delete-channel-modal.test.ts index 2e754583..fccfc32b 100644 --- a/Client/tauri-client/tests/unit/delete-channel-modal.test.ts +++ b/Client/tauri-client/tests/unit/delete-channel-modal.test.ts @@ -50,7 +50,9 @@ describe("DeleteChannelModal", () => { it("calls onConfirm when delete button is clicked", async () => { const onConfirm = vi.fn(async () => {}); const { modal } = makeModal({ onConfirm }); - const deleteBtn = container.querySelector("[data-testid='delete-channel-confirm']") as HTMLButtonElement; + const deleteBtn = container.querySelector( + "[data-testid='delete-channel-confirm']", + ) as HTMLButtonElement; deleteBtn.click(); await vi.waitFor(() => { @@ -88,7 +90,9 @@ describe("DeleteChannelModal", () => { const onConfirm = vi.fn().mockRejectedValue(new Error("Permission denied")); const { modal } = makeModal({ onConfirm }); - const deleteBtn = container.querySelector("[data-testid='delete-channel-confirm']") as HTMLButtonElement; + const deleteBtn = container.querySelector( + "[data-testid='delete-channel-confirm']", + ) as HTMLButtonElement; deleteBtn.click(); await vi.waitFor(() => { @@ -106,7 +110,9 @@ describe("DeleteChannelModal", () => { const onConfirm = vi.fn().mockRejectedValue(42); const { modal } = makeModal({ onConfirm }); - const deleteBtn = container.querySelector("[data-testid='delete-channel-confirm']") as HTMLButtonElement; + const deleteBtn = container.querySelector( + "[data-testid='delete-channel-confirm']", + ) as HTMLButtonElement; deleteBtn.click(); await vi.waitFor(() => { @@ -119,10 +125,17 @@ describe("DeleteChannelModal", () => { it("disables button and shows 'Deleting...' during delete", async () => { let resolveDelete: (() => void) | undefined; - const onConfirm = vi.fn(() => new Promise((resolve) => { resolveDelete = resolve; })); + const onConfirm = vi.fn( + () => + new Promise((resolve) => { + resolveDelete = resolve; + }), + ); const { modal } = makeModal({ onConfirm }); - const deleteBtn = container.querySelector("[data-testid='delete-channel-confirm']") as HTMLButtonElement; + const deleteBtn = container.querySelector( + "[data-testid='delete-channel-confirm']", + ) as HTMLButtonElement; deleteBtn.click(); expect(deleteBtn.hasAttribute("disabled")).toBe(true); @@ -136,7 +149,9 @@ describe("DeleteChannelModal", () => { const onClose = vi.fn(); const { modal } = makeModal({ onClose }); - const overlay = container.querySelector("[data-testid='delete-channel-modal']") as HTMLDivElement; + const overlay = container.querySelector( + "[data-testid='delete-channel-modal']", + ) as HTMLDivElement; overlay.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onClose).toHaveBeenCalled(); diff --git a/Client/tauri-client/tests/unit/device-manager.test.ts b/Client/tauri-client/tests/unit/device-manager.test.ts index 31498420..44c37c10 100644 --- a/Client/tauri-client/tests/unit/device-manager.test.ts +++ b/Client/tauri-client/tests/unit/device-manager.test.ts @@ -106,14 +106,22 @@ describe("DeviceManager", () => { describe("setAudioPipeline", () => { it("accepts null to clear the pipeline", () => { - const pipeline = { setupAudioPipeline: vi.fn(), applyNoiseSuppressor: vi.fn(), removeNoiseSuppressor: vi.fn() } as any; + const pipeline = { + setupAudioPipeline: vi.fn(), + applyNoiseSuppressor: vi.fn(), + removeNoiseSuppressor: vi.fn(), + } as any; dm.setAudioPipeline(pipeline); dm.setAudioPipeline(null); // After clearing, pipeline methods should not be called on device switch }); it("stores a pipeline object for use during device switches", () => { - const pipeline = { setupAudioPipeline: vi.fn(), applyNoiseSuppressor: vi.fn(), removeNoiseSuppressor: vi.fn() } as any; + const pipeline = { + setupAudioPipeline: vi.fn(), + applyNoiseSuppressor: vi.fn(), + removeNoiseSuppressor: vi.fn(), + } as any; dm.setAudioPipeline(pipeline); // Pipeline is stored internally — integration with switchInputDevice tested below }); @@ -212,7 +220,9 @@ describe("DeviceManager", () => { it("shows toast when pipeline setup fails", async () => { const onToast = vi.fn(); const pipeline = { - setupAudioPipeline: vi.fn(() => { throw new Error("pipeline error"); }), + setupAudioPipeline: vi.fn(() => { + throw new Error("pipeline error"); + }), applyNoiseSuppressor: vi.fn().mockResolvedValue(undefined), removeNoiseSuppressor: vi.fn().mockResolvedValue(undefined), } as any; @@ -338,7 +348,9 @@ describe("DeviceManager", () => { await vi.advanceTimersByTimeAsync(600); expect(mockSavePref).toHaveBeenCalledWith("audioOutputDevice", ""); - expect(onToast).toHaveBeenCalledWith("Audio output device disconnected — switched to default"); + expect(onToast).toHaveBeenCalledWith( + "Audio output device disconnected — switched to default", + ); }); it("calls onError when mic fallback fails", async () => { @@ -397,7 +409,9 @@ describe("DeviceManager", () => { }); const pipeline = { - setupAudioPipeline: vi.fn(() => { throw new Error("pipeline error"); }), + setupAudioPipeline: vi.fn(() => { + throw new Error("pipeline error"); + }), } as any; const onToast = vi.fn(); diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts index 9bae0588..0003541b 100644 --- a/Client/tauri-client/tests/unit/dispatcher.test.ts +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -13,6 +13,7 @@ import type { ServerMessage } from "../../src/lib/types"; // Mock notifications and livekitSession to avoid side effects vi.mock("@lib/notifications", () => ({ notifyIncomingMessage: vi.fn(), + cleanupNotificationAudio: vi.fn(), })); vi.mock("@lib/livekitSession", () => ({ handleVoiceToken: vi.fn(async () => {}), @@ -40,10 +41,7 @@ function createMockWs() { connect: vi.fn(), disconnect: vi.fn(), send: vi.fn(() => "test-id"), - on( - type: T, - listener: WsListener, - ): () => void { + on(type: T, listener: WsListener): () => void { if (!listeners.has(type)) { listeners.set(type, new Set()); } @@ -110,7 +108,7 @@ describe("WS Dispatcher", () => { localCamera: false, localScreenshare: false, joinedAt: null, - listenOnly: false, + listenOnly: false, })); dmStore.setState(() => ({ channels: [] })); uiStore.setState((prev) => ({ ...prev, transientError: null })); @@ -148,12 +146,8 @@ describe("WS Dispatcher", () => { { id: 1, name: "general", type: "text", category: null, position: 0 }, { id: 2, name: "voice", type: "voice", category: null, position: 1 }, ], - members: [ - { id: 1, username: "alex", avatar: null, role: "admin", status: "online" }, - ], - voice_states: [ - { channel_id: 2, user_id: 1, muted: false, deafened: false }, - ], + members: [{ id: 1, username: "alex", avatar: null, role: "admin", status: "online" }], + voice_states: [{ channel_id: 2, user_id: 1, muted: false, deafened: false }], roles: [], }); @@ -286,7 +280,13 @@ describe("WS Dispatcher", () => { it("wires member_ban to remove member from members store", () => { membersStore.setState((prev) => { const m = new Map(prev.members); - m.set(77, { id: 77, username: "banned-user", avatar: null, role: "member", status: "online" as const }); + m.set(77, { + id: 77, + username: "banned-user", + avatar: null, + role: "member", + status: "online" as const, + }); return { ...prev, members: m }; }); @@ -297,7 +297,13 @@ describe("WS Dispatcher", () => { it("wires member_leave to members store", () => { membersStore.setState((prev) => { const m = new Map(prev.members); - m.set(99, { id: 99, username: "bye", avatar: null, role: "member", status: "online" as const }); + m.set(99, { + id: 99, + username: "bye", + avatar: null, + role: "member", + status: "online" as const, + }); return { ...prev, members: m }; }); @@ -323,12 +329,10 @@ describe("WS Dispatcher", () => { it("wires ready with DM channels in payload", () => { mock.dispatch("ready", { - channels: [ - { id: 1, name: "general", type: "text", category: null, position: 0 }, - ], + channels: [{ id: 1, name: "general", type: "text", category: null, position: 0 }], members: [], voice_states: [], - roles: [{ id: 1, name: "admin", permissions: 0x7FFFFFFF }], + roles: [{ id: 1, name: "admin", permissions: 0x7fffffff }], dm_channels: [ { channel_id: 100, @@ -369,9 +373,7 @@ describe("WS Dispatcher", () => { })); mock.dispatch("ready", { - channels: [ - { id: 1, name: "general", type: "text", category: null, position: 0 }, - ], + channels: [{ id: 1, name: "general", type: "text", category: null, position: 0 }], members: [], voice_states: [], roles: [], @@ -382,9 +384,7 @@ describe("WS Dispatcher", () => { it("ready with no text channels does not set active", () => { mock.dispatch("ready", { - channels: [ - { id: 5, name: "voice-only", type: "voice", category: null, position: 0 }, - ], + channels: [{ id: 5, name: "voice-only", type: "voice", category: null, position: 0 }], members: [], voice_states: [], roles: [], @@ -564,7 +564,13 @@ describe("WS Dispatcher", () => { it("wires member_update to update role", () => { membersStore.setState((prev) => { const m = new Map(prev.members); - m.set(42, { id: 42, username: "alice", avatar: null, role: "member", status: "online" as const }); + m.set(42, { + id: 42, + username: "alice", + avatar: null, + role: "member", + status: "online" as const, + }); return { ...prev, members: m }; }); @@ -1104,7 +1110,7 @@ describe("WS Dispatcher", () => { // voice_leave should NOT be sent — the LiveKit room is active const sendCalls = (mock.ws.send as ReturnType).mock.calls; const voiceLeaveSent = sendCalls.some( - ([msg]: [{ type: string }]) => msg.type === "voice_leave", + (args: unknown[]) => (args[0] as Record)?.type === "voice_leave", ); expect(voiceLeaveSent).toBe(false); }); @@ -1131,7 +1137,7 @@ describe("WS Dispatcher", () => { // voice_leave should NOT be sent — user 42 is not in voice_states const sendCalls = (mock.ws.send as ReturnType).mock.calls; const voiceLeaveSent = sendCalls.some( - ([msg]: [{ type: string }]) => msg.type === "voice_leave", + (args: unknown[]) => (args[0] as Record)?.type === "voice_leave", ); expect(voiceLeaveSent).toBe(false); }); diff --git a/Client/tauri-client/tests/unit/dm-store.test.ts b/Client/tauri-client/tests/unit/dm-store.test.ts index 15015117..88160427 100644 --- a/Client/tauri-client/tests/unit/dm-store.test.ts +++ b/Client/tauri-client/tests/unit/dm-store.test.ts @@ -65,10 +65,7 @@ describe("dmStore", () => { }); it("updates an existing channel without creating a duplicate", () => { - setDmChannels([ - makeDm({ channelId: 1, lastMessage: "old" }), - makeDm({ channelId: 2 }), - ]); + setDmChannels([makeDm({ channelId: 1, lastMessage: "old" }), makeDm({ channelId: 2 })]); addDmChannel(makeDm({ channelId: 1, lastMessage: "new" })); const channels = dmStore.getState().channels; expect(channels).toHaveLength(2); @@ -78,11 +75,7 @@ describe("dmStore", () => { }); it("moves an updated existing channel to the front", () => { - setDmChannels([ - makeDm({ channelId: 1 }), - makeDm({ channelId: 2 }), - makeDm({ channelId: 3 }), - ]); + setDmChannels([makeDm({ channelId: 1 }), makeDm({ channelId: 2 }), makeDm({ channelId: 3 })]); addDmChannel(makeDm({ channelId: 3, lastMessage: "bumped" })); const channels = dmStore.getState().channels; expect(channels[0]!.channelId).toBe(3); @@ -168,11 +161,7 @@ describe("dmStore", () => { }); it("moves the updated channel to the front of the list", () => { - setDmChannels([ - makeDm({ channelId: 1 }), - makeDm({ channelId: 2 }), - makeDm({ channelId: 3 }), - ]); + setDmChannels([makeDm({ channelId: 1 }), makeDm({ channelId: 2 }), makeDm({ channelId: 3 })]); updateDmLastMessagePreview(3, 50, "latest", "2026-03-28T14:00:00Z"); const channels = dmStore.getState().channels; expect(channels[0]!.channelId).toBe(3); @@ -213,11 +202,7 @@ describe("dmStore", () => { describe("updateDmLastMessage — reordering", () => { it("moves the updated channel to the front of the list", () => { - setDmChannels([ - makeDm({ channelId: 1 }), - makeDm({ channelId: 2 }), - makeDm({ channelId: 3 }), - ]); + setDmChannels([makeDm({ channelId: 1 }), makeDm({ channelId: 2 }), makeDm({ channelId: 3 })]); updateDmLastMessage(3, 50, "new", "2026-03-28T14:00:00Z"); const channels = dmStore.getState().channels; expect(channels[0]!.channelId).toBe(3); diff --git a/Client/tauri-client/tests/unit/edit-channel-modal.test.ts b/Client/tauri-client/tests/unit/edit-channel-modal.test.ts index 37bf033d..4b201c5f 100644 --- a/Client/tauri-client/tests/unit/edit-channel-modal.test.ts +++ b/Client/tauri-client/tests/unit/edit-channel-modal.test.ts @@ -36,7 +36,9 @@ describe("EditChannelModal", () => { it("pre-fills the name input with current channel name", () => { const { modal } = makeModal(); - const input = container.querySelector("[data-testid='edit-channel-name-input']") as HTMLInputElement; + const input = container.querySelector( + "[data-testid='edit-channel-name-input']", + ) as HTMLInputElement; expect(input.value).toBe("general"); modal.destroy?.(); }); @@ -51,10 +53,14 @@ describe("EditChannelModal", () => { it("shows error when saving with empty name", () => { const onSave = vi.fn(async () => {}); const { modal } = makeModal({ onSave }); - const input = container.querySelector("[data-testid='edit-channel-name-input']") as HTMLInputElement; + const input = container.querySelector( + "[data-testid='edit-channel-name-input']", + ) as HTMLInputElement; input.value = ""; - const saveBtn = container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement; + const saveBtn = container.querySelector( + "[data-testid='edit-channel-submit']", + ) as HTMLButtonElement; saveBtn.click(); const error = container.querySelector("[data-testid='edit-channel-error']"); @@ -66,10 +72,14 @@ describe("EditChannelModal", () => { it("calls onSave with updated name", async () => { const onSave = vi.fn(async () => {}); const { modal } = makeModal({ onSave }); - const input = container.querySelector("[data-testid='edit-channel-name-input']") as HTMLInputElement; + const input = container.querySelector( + "[data-testid='edit-channel-name-input']", + ) as HTMLInputElement; input.value = "renamed-channel"; - const saveBtn = container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement; + const saveBtn = container.querySelector( + "[data-testid='edit-channel-submit']", + ) as HTMLButtonElement; saveBtn.click(); await vi.waitFor(() => { @@ -98,10 +108,14 @@ describe("EditChannelModal", () => { const onSave = vi.fn().mockRejectedValue(new Error("Server error")); const { modal } = makeModal({ onSave }); - const input = container.querySelector("[data-testid='edit-channel-name-input']") as HTMLInputElement; + const input = container.querySelector( + "[data-testid='edit-channel-name-input']", + ) as HTMLInputElement; input.value = "new-name"; - const saveBtn = container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement; + const saveBtn = container.querySelector( + "[data-testid='edit-channel-submit']", + ) as HTMLButtonElement; saveBtn.click(); await vi.waitFor(() => { @@ -120,10 +134,14 @@ describe("EditChannelModal", () => { const onSave = vi.fn().mockRejectedValue("unknown"); const { modal } = makeModal({ onSave }); - const input = container.querySelector("[data-testid='edit-channel-name-input']") as HTMLInputElement; + const input = container.querySelector( + "[data-testid='edit-channel-name-input']", + ) as HTMLInputElement; input.value = "new-name"; - const saveBtn = container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement; + const saveBtn = container.querySelector( + "[data-testid='edit-channel-submit']", + ) as HTMLButtonElement; saveBtn.click(); await vi.waitFor(() => { @@ -136,13 +154,22 @@ describe("EditChannelModal", () => { it("disables save button and shows 'Saving...' during save", async () => { let resolveSave: (() => void) | undefined; - const onSave = vi.fn(() => new Promise((resolve) => { resolveSave = resolve; })); + const onSave = vi.fn( + () => + new Promise((resolve) => { + resolveSave = resolve; + }), + ); const { modal } = makeModal({ onSave }); - const input = container.querySelector("[data-testid='edit-channel-name-input']") as HTMLInputElement; + const input = container.querySelector( + "[data-testid='edit-channel-name-input']", + ) as HTMLInputElement; input.value = "renamed"; - const saveBtn = container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement; + const saveBtn = container.querySelector( + "[data-testid='edit-channel-submit']", + ) as HTMLButtonElement; saveBtn.click(); expect(saveBtn.hasAttribute("disabled")).toBe(true); @@ -178,10 +205,14 @@ describe("EditChannelModal", () => { const onSave = vi.fn(async () => {}); const { modal } = makeModal({ onSave }); - const input = container.querySelector("[data-testid='edit-channel-name-input']") as HTMLInputElement; + const input = container.querySelector( + "[data-testid='edit-channel-name-input']", + ) as HTMLInputElement; input.value = " "; // whitespace only - const saveBtn = container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement; + const saveBtn = container.querySelector( + "[data-testid='edit-channel-submit']", + ) as HTMLButtonElement; saveBtn.click(); expect(input.classList.contains("error")).toBe(true); diff --git a/Client/tauri-client/tests/unit/embeds.test.ts b/Client/tauri-client/tests/unit/embeds.test.ts index a448a0fc..bfefbc16 100644 --- a/Client/tauri-client/tests/unit/embeds.test.ts +++ b/Client/tauri-client/tests/unit/embeds.test.ts @@ -1,11 +1,4 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { fetchMock } = vi.hoisted(() => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -24,7 +17,12 @@ vi.mock("@lib/media-visibility", () => ({ observeMedia: mockObserveMedia, })); -import { clearEmbedCaches, renderGenericLinkPreview, parseOgTags, applyOgMeta } from "../../src/components/message-list/embeds"; +import { + clearEmbedCaches, + renderGenericLinkPreview, + parseOgTags, + applyOgMeta, +} from "../../src/components/message-list/embeds"; import type { OgMeta } from "../../src/components/message-list/embeds"; import { setServerHost } from "../../src/components/message-list/attachments"; @@ -54,9 +52,12 @@ describe("renderGenericLinkPreview", () => { it("does not reuse OG metadata that resolves after the cache was cleared", async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let resolveFetch: ((value: any) => void) | null = null; - fetchMock.mockImplementationOnce((() => new Promise((resolve) => { - resolveFetch = resolve; - })) as any); + fetchMock.mockImplementationOnce( + (() => + new Promise((resolve) => { + resolveFetch = resolve; + })) as any, + ); const first = renderGenericLinkPreview("https://news.example.com/post"); document.body.appendChild(first); @@ -67,7 +68,9 @@ describe("renderGenericLinkPreview", () => { await Promise.resolve(); await Promise.resolve(); - fetchMock.mockResolvedValueOnce(mockHtmlResponse("Fresh")); + fetchMock.mockResolvedValueOnce( + mockHtmlResponse("Fresh"), + ); const second = renderGenericLinkPreview("https://news.example.com/post"); document.body.appendChild(second); @@ -79,9 +82,12 @@ describe("renderGenericLinkPreview", () => { it("does not reuse an EMPTY_OG result that resolves after the cache was cleared", async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let resolveFetch: ((value: any) => void) | null = null; - fetchMock.mockImplementationOnce((() => new Promise((resolve) => { - resolveFetch = resolve; - })) as any); + fetchMock.mockImplementationOnce( + (() => + new Promise((resolve) => { + resolveFetch = resolve; + })) as any, + ); const first = renderGenericLinkPreview("https://news.example.com/empty"); document.body.appendChild(first); @@ -95,7 +101,9 @@ describe("renderGenericLinkPreview", () => { await Promise.resolve(); await Promise.resolve(); - fetchMock.mockResolvedValueOnce(mockHtmlResponse("Recovered")); + fetchMock.mockResolvedValueOnce( + mockHtmlResponse("Recovered"), + ); const second = renderGenericLinkPreview("https://news.example.com/empty"); document.body.appendChild(second); @@ -110,12 +118,18 @@ describe("renderGenericLinkPreview", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let resolveSecond: ((value: any) => void) | null = null; fetchMock - .mockImplementationOnce((() => new Promise((resolve) => { - resolveFirst = resolve; - })) as any) - .mockImplementationOnce((() => new Promise((resolve) => { - resolveSecond = resolve; - })) as any); + .mockImplementationOnce( + (() => + new Promise((resolve) => { + resolveFirst = resolve; + })) as any, + ) + .mockImplementationOnce( + (() => + new Promise((resolve) => { + resolveSecond = resolve; + })) as any, + ); const first = renderGenericLinkPreview("https://news.example.com/race"); document.body.appendChild(first); @@ -144,7 +158,9 @@ describe("renderGenericLinkPreview", () => { }); it("fetches OG metadata for public domains that begin with fd", async () => { - fetchMock.mockResolvedValue(mockHtmlResponse("F-Droid")); + fetchMock.mockResolvedValue( + mockHtmlResponse("F-Droid"), + ); const card = renderGenericLinkPreview("https://fdroid.org/packages"); document.body.appendChild(card); @@ -213,7 +229,9 @@ describe("renderGenericLinkPreview", () => { it("allows previews for the configured OwnCord server even on private hosts", async () => { setServerHost("LOCALHOST:8080"); - fetchMock.mockResolvedValue(mockHtmlResponse("OwnCord Local")); + fetchMock.mockResolvedValue( + mockHtmlResponse("OwnCord Local"), + ); const card = renderGenericLinkPreview("https://localhost:8080/docs"); document.body.appendChild(card); @@ -298,7 +316,9 @@ describe("renderGenericLinkPreview", () => { it("renders from cache on second call (no second fetch)", async () => { clearEmbedCaches(); fetchMock.mockResolvedValueOnce( - mockHtmlResponse(''), + mockHtmlResponse( + '', + ), ); const card1 = renderGenericLinkPreview("https://cached.example.com/page"); @@ -333,7 +353,9 @@ describe("renderGenericLinkPreview", () => { it("allows 172.32.x.x (not private)", async () => { clearEmbedCaches(); - fetchMock.mockResolvedValueOnce(mockHtmlResponse("Public")); + fetchMock.mockResolvedValueOnce( + mockHtmlResponse("Public"), + ); const card = renderGenericLinkPreview("https://172.32.0.1/page"); document.body.appendChild(card); await vi.waitFor(() => { @@ -415,7 +437,9 @@ describe("renderGenericLinkPreview", () => { it("allows public IPv6 addresses", async () => { clearEmbedCaches(); - fetchMock.mockResolvedValueOnce(mockHtmlResponse("IPv6")); + fetchMock.mockResolvedValueOnce( + mockHtmlResponse("IPv6"), + ); const card = renderGenericLinkPreview("https://[2600::1]/page"); document.body.appendChild(card); await vi.waitFor(() => { @@ -495,7 +519,15 @@ describe("applyOgMeta", () => { const imageWrap = document.createElement("div"); const meta: OgMeta = { title: "Page Title", description: null, image: null, siteName: null }; - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, "https://example.com/page", "example.com"); + applyOgMeta( + meta, + titleEl, + descEl, + hostEl, + imageWrap, + "https://example.com/page", + "example.com", + ); expect(titleEl.textContent).toBe("Page Title"); }); @@ -507,7 +539,15 @@ describe("applyOgMeta", () => { const imageWrap = document.createElement("div"); const meta: OgMeta = { title: null, description: null, image: null, siteName: null }; - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, "https://example.com/page", "example.com"); + applyOgMeta( + meta, + titleEl, + descEl, + hostEl, + imageWrap, + "https://example.com/page", + "example.com", + ); expect(titleEl.textContent).toBe("example.com"); }); @@ -519,7 +559,15 @@ describe("applyOgMeta", () => { const imageWrap = document.createElement("div"); const meta: OgMeta = { title: "Title", description: null, image: null, siteName: "My Site" }; - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, "https://example.com/page", "example.com"); + applyOgMeta( + meta, + titleEl, + descEl, + hostEl, + imageWrap, + "https://example.com/page", + "example.com", + ); expect(hostEl.textContent).toBe("My Site"); }); @@ -532,7 +580,15 @@ describe("applyOgMeta", () => { const longDesc = "A".repeat(300); const meta: OgMeta = { title: "Title", description: longDesc, image: null, siteName: null }; - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, "https://example.com/page", "example.com"); + applyOgMeta( + meta, + titleEl, + descEl, + hostEl, + imageWrap, + "https://example.com/page", + "example.com", + ); expect(descEl.textContent!.length).toBe(200); expect(descEl.textContent!.endsWith("...")).toBe(true); @@ -545,8 +601,21 @@ describe("applyOgMeta", () => { const hostEl = document.createElement("div"); const imageWrap = document.createElement("div"); - const meta: OgMeta = { title: "Title", description: "Short description", image: null, siteName: null }; - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, "https://example.com/page", "example.com"); + const meta: OgMeta = { + title: "Title", + description: "Short description", + image: null, + siteName: null, + }; + applyOgMeta( + meta, + titleEl, + descEl, + hostEl, + imageWrap, + "https://example.com/page", + "example.com", + ); expect(descEl.textContent).toBe("Short description"); expect(descEl.style.display).toBe(""); @@ -559,7 +628,15 @@ describe("applyOgMeta", () => { const imageWrap = document.createElement("div"); const meta: OgMeta = { title: "Title", description: null, image: null, siteName: null }; - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, "https://example.com/page", "example.com"); + applyOgMeta( + meta, + titleEl, + descEl, + hostEl, + imageWrap, + "https://example.com/page", + "example.com", + ); expect(descEl.style.display).toBe("none"); }); @@ -576,7 +653,15 @@ describe("applyOgMeta", () => { image: "https://example.com/image.jpg", siteName: null, }; - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, "https://example.com/page", "example.com"); + applyOgMeta( + meta, + titleEl, + descEl, + hostEl, + imageWrap, + "https://example.com/page", + "example.com", + ); const img = imageWrap.querySelector("img"); expect(img).not.toBeNull(); @@ -596,7 +681,15 @@ describe("applyOgMeta", () => { image: "/images/og.png", siteName: null, }; - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, "https://example.com/page", "example.com"); + applyOgMeta( + meta, + titleEl, + descEl, + hostEl, + imageWrap, + "https://example.com/page", + "example.com", + ); const img = imageWrap.querySelector("img"); expect(img).not.toBeNull(); @@ -615,7 +708,15 @@ describe("applyOgMeta", () => { image: "https://example.com/image.jpg", siteName: null, }; - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, "https://example.com/page", "example.com"); + applyOgMeta( + meta, + titleEl, + descEl, + hostEl, + imageWrap, + "https://example.com/page", + "example.com", + ); const img = imageWrap.querySelector("img")!; img.dispatchEvent(new Event("error")); @@ -630,7 +731,15 @@ describe("applyOgMeta", () => { const imageWrap = document.createElement("div"); const meta: OgMeta = { title: "Title", description: null, image: "", siteName: null }; - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, "https://example.com/page", "example.com"); + applyOgMeta( + meta, + titleEl, + descEl, + hostEl, + imageWrap, + "https://example.com/page", + "example.com", + ); expect(imageWrap.querySelector("img")).toBeNull(); }); @@ -647,7 +756,15 @@ describe("applyOgMeta", () => { image: "https://192.168.1.1/image.png", siteName: null, }; - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, "https://example.com/page", "example.com"); + applyOgMeta( + meta, + titleEl, + descEl, + hostEl, + imageWrap, + "https://example.com/page", + "example.com", + ); expect(imageWrap.querySelector("img")).toBeNull(); }); @@ -664,7 +781,15 @@ describe("applyOgMeta", () => { image: "https://example.com/animated.gif", siteName: null, }; - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, "https://example.com/page", "example.com"); + applyOgMeta( + meta, + titleEl, + descEl, + hostEl, + imageWrap, + "https://example.com/page", + "example.com", + ); const img = imageWrap.querySelector("img"); expect(img).not.toBeNull(); @@ -683,7 +808,15 @@ describe("applyOgMeta", () => { image: "https://example.com/animated.gif", siteName: null, }; - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, "https://example.com/page", "example.com"); + applyOgMeta( + meta, + titleEl, + descEl, + hostEl, + imageWrap, + "https://example.com/page", + "example.com", + ); const img = imageWrap.querySelector("img")!; img.dispatchEvent(new Event("load")); @@ -710,9 +843,12 @@ describe("renderGenericLinkPreview — cache stale during non-HTML response", () it("discards non-HTML response result when cache was cleared mid-flight", async () => { let resolveFetch: ((value: unknown) => void) | null = null; - fetchMock.mockImplementationOnce(() => new Promise((resolve) => { - resolveFetch = resolve; - })); + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); const card = renderGenericLinkPreview("https://stale-nonhtml.example.com/data"); document.body.appendChild(card); @@ -724,7 +860,9 @@ describe("renderGenericLinkPreview — cache stale during non-HTML response", () // Resolve with non-HTML content type (resolveFetch as any)?.({ ok: true, - headers: { get: (name: string) => name.toLowerCase() === "content-type" ? "application/json" : null }, + headers: { + get: (name: string) => (name.toLowerCase() === "content-type" ? "application/json" : null), + }, text: vi.fn().mockResolvedValue("{}"), }); @@ -733,7 +871,9 @@ describe("renderGenericLinkPreview — cache stale during non-HTML response", () // Should have discarded the result (generation mismatch) // A new fetch for the same URL should still trigger a new fetch - fetchMock.mockResolvedValueOnce(mockHtmlResponse("Fresh")); + fetchMock.mockResolvedValueOnce( + mockHtmlResponse("Fresh"), + ); const card2 = renderGenericLinkPreview("https://stale-nonhtml.example.com/data"); document.body.appendChild(card2); await vi.waitFor(() => { @@ -743,9 +883,12 @@ describe("renderGenericLinkPreview — cache stale during non-HTML response", () it("discards fetch error result when cache was cleared mid-flight", async () => { let rejectFetch: ((err: Error) => void) | null = null; - fetchMock.mockImplementationOnce(() => new Promise((_resolve, reject) => { - rejectFetch = reject; - })); + fetchMock.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFetch = reject; + }), + ); const card = renderGenericLinkPreview("https://stale-error.example.com/fail"); document.body.appendChild(card); @@ -761,11 +904,13 @@ describe("renderGenericLinkPreview — cache stale during non-HTML response", () await Promise.resolve(); // A new fetch for the same URL should still trigger a new fetch - fetchMock.mockResolvedValueOnce(mockHtmlResponse("Recovered")); + fetchMock.mockResolvedValueOnce( + mockHtmlResponse("Recovered"), + ); const card2 = renderGenericLinkPreview("https://stale-error.example.com/fail"); document.body.appendChild(card2); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(2); }); }); -}); \ No newline at end of file +}); diff --git a/Client/tauri-client/tests/unit/emoji-picker.test.ts b/Client/tauri-client/tests/unit/emoji-picker.test.ts index a00f992c..53a78747 100644 --- a/Client/tauri-client/tests/unit/emoji-picker.test.ts +++ b/Client/tauri-client/tests/unit/emoji-picker.test.ts @@ -118,9 +118,7 @@ describe("EmojiPicker", () => { it("renders custom emoji when provided", () => { const { picker } = makePicker({ - customEmoji: [ - { shortcode: "test_emoji", url: "https://example.com/emoji.png" }, - ], + customEmoji: [{ shortcode: "test_emoji", url: "https://example.com/emoji.png" }], }); const labels = picker.element.querySelectorAll(".ep-category-label"); diff --git a/Client/tauri-client/tests/unit/file-upload.test.ts b/Client/tauri-client/tests/unit/file-upload.test.ts index 19f697bc..86769c99 100644 --- a/Client/tauri-client/tests/unit/file-upload.test.ts +++ b/Client/tauri-client/tests/unit/file-upload.test.ts @@ -126,7 +126,7 @@ describe("FileUpload", () => { const onUpload = vi.fn(async () => {}); const upload = makeUpload({ onUpload, maxSizeMb: 5 }); - const bigFile = new File(["x"], "huge.bin", { type: "application/octet-stream" }); + const bigFile = new File(["x"], "huge.pdf", { type: "application/pdf" }); Object.defineProperty(bigFile, "size", { value: 6 * 1024 * 1024 }); // 6 MB > 5 MB limit const input = container.querySelector(".file-upload__input") as HTMLInputElement; @@ -156,7 +156,7 @@ describe("FileUpload", () => { const onUpload = vi.fn(async () => {}); const upload = makeUpload({ onUpload }); - const bigFile = new File(["x"], "huge.bin", { type: "application/octet-stream" }); + const bigFile = new File(["x"], "huge.pdf", { type: "application/pdf" }); Object.defineProperty(bigFile, "size", { value: 11 * 1024 * 1024 }); // 11 MB > 10 MB default const input = container.querySelector(".file-upload__input") as HTMLInputElement; @@ -288,9 +288,12 @@ describe("FileUpload", () => { it("cancel button aborts in-flight upload and resets preview", async () => { let resolveUpload: (() => void) | undefined; - const onUpload = vi.fn(() => new Promise((resolve) => { - resolveUpload = resolve; - })); + const onUpload = vi.fn( + () => + new Promise((resolve) => { + resolveUpload = resolve; + }), + ); const upload = makeUpload({ onUpload }); const file = new File(["data"], "test.txt", { type: "text/plain" }); @@ -327,7 +330,9 @@ describe("FileUpload", () => { const root = container.querySelector(".file-upload") as HTMLDivElement; const file = new File(["dropped"], "dropped.pdf", { type: "application/pdf" }); - const dropEvent = new Event("drop", { bubbles: true }) as Event & { dataTransfer?: { files: File[] } }; + const dropEvent = new Event("drop", { bubbles: true }) as Event & { + dataTransfer?: { files: File[] }; + }; Object.defineProperty(dropEvent, "dataTransfer", { value: { files: [file] }, }); @@ -404,9 +409,12 @@ describe("FileUpload", () => { it("destroy aborts in-flight upload", async () => { let resolveUpload: (() => void) | undefined; - const onUpload = vi.fn(() => new Promise((resolve) => { - resolveUpload = resolve; - })); + const onUpload = vi.fn( + () => + new Promise((resolve) => { + resolveUpload = resolve; + }), + ); const upload = makeUpload({ onUpload }); const file = new File(["data"], "test.txt", { type: "text/plain" }); @@ -448,7 +456,7 @@ describe("FileUpload", () => { const onUpload = vi.fn(async () => {}); const upload = makeUpload({ onUpload, maxSizeMb: 20 }); - const file = new File(["x"], "big.bin", { type: "application/octet-stream" }); + const file = new File(["x"], "big.pdf", { type: "application/pdf" }); Object.defineProperty(file, "size", { value: 5 * 1024 * 1024 }); // 5 MB const input = container.querySelector(".file-upload__input") as HTMLInputElement; diff --git a/Client/tauri-client/tests/unit/gif-picker.test.ts b/Client/tauri-client/tests/unit/gif-picker.test.ts index 1f554371..1c24349d 100644 --- a/Client/tauri-client/tests/unit/gif-picker.test.ts +++ b/Client/tauri-client/tests/unit/gif-picker.test.ts @@ -28,16 +28,9 @@ function makeGif(id: string): TenorGif { }; } -const TRENDING_GIFS: readonly TenorGif[] = [ - makeGif("t1"), - makeGif("t2"), - makeGif("t3"), -]; +const TRENDING_GIFS: readonly TenorGif[] = [makeGif("t1"), makeGif("t2"), makeGif("t3")]; -const SEARCH_GIFS: readonly TenorGif[] = [ - makeGif("s1"), - makeGif("s2"), -]; +const SEARCH_GIFS: readonly TenorGif[] = [makeGif("s1"), makeGif("s2")]; // --------------------------------------------------------------------------- // Helpers @@ -377,9 +370,7 @@ describe("GifPicker", () => { const { picker } = makePicker({ onClose }); container.appendChild(picker.element); - picker.element.dispatchEvent( - new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), - ); + picker.element.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); expect(onClose).toHaveBeenCalledOnce(); picker.destroy(); @@ -390,12 +381,8 @@ describe("GifPicker", () => { const { picker } = makePicker({ onClose }); container.appendChild(picker.element); - picker.element.dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), - ); - picker.element.dispatchEvent( - new KeyboardEvent("keydown", { key: "Tab", bubbles: true }), - ); + picker.element.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + picker.element.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); expect(onClose).not.toHaveBeenCalled(); picker.destroy(); @@ -549,8 +536,8 @@ describe("GifPicker", () => { vi.mocked(getTrendingGifs).mockResolvedValue(TRENDING_GIFS); vi.mocked(searchGifs) - .mockReturnValueOnce(firstPromise) // "ca" — resolves late - .mockResolvedValueOnce(SEARCH_GIFS); // "cats" — resolves immediately + .mockReturnValueOnce(firstPromise) // "ca" — resolves late + .mockResolvedValueOnce(SEARCH_GIFS); // "cats" — resolves immediately const { picker } = makePicker(); container.appendChild(picker.element); @@ -631,9 +618,7 @@ describe("GifPicker", () => { picker.destroy(); - picker.element.dispatchEvent( - new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), - ); + picker.element.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); expect(onClose).not.toHaveBeenCalled(); }); diff --git a/Client/tauri-client/tests/unit/icons.test.ts b/Client/tauri-client/tests/unit/icons.test.ts index 6b38d03d..92793672 100644 --- a/Client/tauri-client/tests/unit/icons.test.ts +++ b/Client/tauri-client/tests/unit/icons.test.ts @@ -110,7 +110,7 @@ describe("createIcon", () => { const svg = createIcon(name); expect( svg.innerHTML.trim().length, - `Expected non-empty innerHTML for icon "${name}"` + `Expected non-empty innerHTML for icon "${name}"`, ).toBeGreaterThan(0); } }); diff --git a/Client/tauri-client/tests/unit/keybinds-tab.test.ts b/Client/tauri-client/tests/unit/keybinds-tab.test.ts index 417bf914..3315ad28 100644 --- a/Client/tauri-client/tests/unit/keybinds-tab.test.ts +++ b/Client/tauri-client/tests/unit/keybinds-tab.test.ts @@ -81,7 +81,9 @@ describe("KeybindsTab", () => { it("shows 'Press any key...' when capture button is clicked", () => { const el = buildKeybindsTab(new AbortController().signal); mockCaptureKeyPress.mockReturnValue(new Promise(() => {})); // never resolves - const pttBtn = el.querySelectorAll(".keybind-row")[0]!.querySelector(".kbd") as HTMLButtonElement; + const pttBtn = el + .querySelectorAll(".keybind-row")[0]! + .querySelector(".kbd") as HTMLButtonElement; pttBtn.click(); @@ -94,7 +96,9 @@ describe("KeybindsTab", () => { const el = buildKeybindsTab(new AbortController().signal); mockCaptureKeyPress.mockResolvedValue(0x05); // Mouse 5 mockVkName.mockReturnValue("Mouse 5"); - const pttBtn = el.querySelectorAll(".keybind-row")[0]!.querySelector(".kbd") as HTMLButtonElement; + const pttBtn = el + .querySelectorAll(".keybind-row")[0]! + .querySelector(".kbd") as HTMLButtonElement; pttBtn.click(); @@ -109,7 +113,9 @@ describe("KeybindsTab", () => { it("restores previous value when captureKeyPress times out (returns 0)", async () => { const el = buildKeybindsTab(new AbortController().signal); mockCaptureKeyPress.mockResolvedValue(0); - const pttBtn = el.querySelectorAll(".keybind-row")[0]!.querySelector(".kbd") as HTMLButtonElement; + const pttBtn = el + .querySelectorAll(".keybind-row")[0]! + .querySelector(".kbd") as HTMLButtonElement; pttBtn.click(); @@ -122,7 +128,9 @@ describe("KeybindsTab", () => { it("restores previous value on captureKeyPress failure (fallback path)", async () => { const el = buildKeybindsTab(new AbortController().signal); mockCaptureKeyPress.mockRejectedValue(new Error("No Tauri")); - const pttBtn = el.querySelectorAll(".keybind-row")[0]!.querySelector(".kbd") as HTMLButtonElement; + const pttBtn = el + .querySelectorAll(".keybind-row")[0]! + .querySelector(".kbd") as HTMLButtonElement; pttBtn.click(); @@ -136,7 +144,9 @@ describe("KeybindsTab", () => { it("ignores click when already capturing", () => { const el = buildKeybindsTab(new AbortController().signal); mockCaptureKeyPress.mockReturnValue(new Promise(() => {})); // never resolves - const pttBtn = el.querySelectorAll(".keybind-row")[0]!.querySelector(".kbd") as HTMLButtonElement; + const pttBtn = el + .querySelectorAll(".keybind-row")[0]! + .querySelector(".kbd") as HTMLButtonElement; pttBtn.click(); expect(pttBtn.textContent).toBe("Press any key..."); @@ -247,7 +257,9 @@ describe("KeybindsTab", () => { mockCaptureKeyPress.mockResolvedValue(0); const el = buildKeybindsTab(new AbortController().signal); - const pttBtn = el.querySelectorAll(".keybind-row")[0]!.querySelector(".kbd") as HTMLButtonElement; + const pttBtn = el + .querySelectorAll(".keybind-row")[0]! + .querySelector(".kbd") as HTMLButtonElement; expect(pttBtn.textContent).toBe("F2"); @@ -266,7 +278,9 @@ describe("KeybindsTab", () => { mockCaptureKeyPress.mockRejectedValue(new Error("No Tauri")); const el = buildKeybindsTab(new AbortController().signal); - const pttBtn = el.querySelectorAll(".keybind-row")[0]!.querySelector(".kbd") as HTMLButtonElement; + const pttBtn = el + .querySelectorAll(".keybind-row")[0]! + .querySelector(".kbd") as HTMLButtonElement; expect(pttBtn.textContent).toBe("F2"); diff --git a/Client/tauri-client/tests/unit/log-persistence.test.ts b/Client/tauri-client/tests/unit/log-persistence.test.ts index 3606e84d..cfaf0b86 100644 --- a/Client/tauri-client/tests/unit/log-persistence.test.ts +++ b/Client/tauri-client/tests/unit/log-persistence.test.ts @@ -64,12 +64,10 @@ function captureListener(): { getListener: () => ((entry: unknown) => void) | null; } { let listener: ((entry: unknown) => void) | null = null; - mockAddLogListener.mockImplementation( - (cb: (entry: unknown) => void) => { - listener = cb; - return () => {}; - }, - ); + mockAddLogListener.mockImplementation((cb: (entry: unknown) => void) => { + listener = cb; + return () => {}; + }); return { getListener: () => listener, }; @@ -129,10 +127,7 @@ describe("log persistence", () => { captureListener(); await initLogPersistence(); - expect(mockMkdir).toHaveBeenCalledWith( - "/mock/logs/client-logs", - { recursive: true }, - ); + expect(mockMkdir).toHaveBeenCalledWith("/mock/logs/client-logs", { recursive: true }); }); it("does NOT create the log directory when it already exists", async () => { @@ -380,8 +375,7 @@ describe("log persistence", () => { describe("clearPendingPersistedLogs", () => { it("clears the buffer so pending entries are discarded", async () => { const { getListener } = captureListener(); - const { initLogPersistence, clearPendingPersistedLogs } = - await freshImport(); + const { initLogPersistence, clearPendingPersistedLogs } = await freshImport(); await initLogPersistence(); getListener()!(makeEntry({ message: "will-be-cleared" })); @@ -395,8 +389,7 @@ describe("log persistence", () => { it("clears a pending flush timer", async () => { const { getListener } = captureListener(); - const { initLogPersistence, clearPendingPersistedLogs } = - await freshImport(); + const { initLogPersistence, clearPendingPersistedLogs } = await freshImport(); await initLogPersistence(); getListener()!(makeEntry()); @@ -418,8 +411,7 @@ describe("log persistence", () => { }), ); - const { initLogPersistence, clearPendingPersistedLogs } = - await freshImport(); + const { initLogPersistence, clearPendingPersistedLogs } = await freshImport(); await initLogPersistence(); getListener()!(makeEntry()); @@ -487,12 +479,8 @@ describe("log persistence", () => { // Should have removed the oldest files (7 files, keep 5 => remove 2) expect(mockRemove).toHaveBeenCalledTimes(2); - expect(mockRemove).toHaveBeenCalledWith( - "/mock/logs/client-logs/2025-06-10.jsonl", - ); - expect(mockRemove).toHaveBeenCalledWith( - "/mock/logs/client-logs/2025-06-11.jsonl", - ); + expect(mockRemove).toHaveBeenCalledWith("/mock/logs/client-logs/2025-06-10.jsonl"); + expect(mockRemove).toHaveBeenCalledWith("/mock/logs/client-logs/2025-06-11.jsonl"); }); it("does not rotate when file count is within MAX_LOG_FILES", async () => { @@ -584,9 +572,7 @@ describe("log persistence", () => { // 6 jsonl files - keep 5 = remove 1 expect(mockRemove).toHaveBeenCalledTimes(1); - expect(mockRemove).toHaveBeenCalledWith( - "/mock/logs/client-logs/2025-06-10.jsonl", - ); + expect(mockRemove).toHaveBeenCalledWith("/mock/logs/client-logs/2025-06-10.jsonl"); }); }); @@ -602,8 +588,7 @@ describe("log persistence", () => { it("reads and concatenates all jsonl files in sorted order", async () => { captureListener(); - const { initLogPersistence, readAllPersistedLogs } = - await freshImport(); + const { initLogPersistence, readAllPersistedLogs } = await freshImport(); await initLogPersistence(); mockReadDir.mockResolvedValueOnce([ @@ -630,8 +615,7 @@ describe("log persistence", () => { it("filters out directories and non-jsonl entries", async () => { captureListener(); - const { initLogPersistence, readAllPersistedLogs } = - await freshImport(); + const { initLogPersistence, readAllPersistedLogs } = await freshImport(); await initLogPersistence(); mockReadDir.mockResolvedValueOnce([ @@ -650,13 +634,10 @@ describe("log persistence", () => { it("returns empty string when directory has no jsonl files", async () => { captureListener(); - const { initLogPersistence, readAllPersistedLogs } = - await freshImport(); + const { initLogPersistence, readAllPersistedLogs } = await freshImport(); await initLogPersistence(); - mockReadDir.mockResolvedValueOnce([ - { name: "notes.txt", isDirectory: false }, - ]); + mockReadDir.mockResolvedValueOnce([{ name: "notes.txt", isDirectory: false }]); const result = await readAllPersistedLogs(); expect(result).toBe(""); @@ -665,8 +646,7 @@ describe("log persistence", () => { it("returns empty string on readDir failure", async () => { captureListener(); - const { initLogPersistence, readAllPersistedLogs } = - await freshImport(); + const { initLogPersistence, readAllPersistedLogs } = await freshImport(); await initLogPersistence(); mockReadDir.mockRejectedValueOnce(new Error("no access")); @@ -677,13 +657,10 @@ describe("log persistence", () => { it("returns empty string on readTextFile failure", async () => { captureListener(); - const { initLogPersistence, readAllPersistedLogs } = - await freshImport(); + const { initLogPersistence, readAllPersistedLogs } = await freshImport(); await initLogPersistence(); - mockReadDir.mockResolvedValueOnce([ - { name: "2025-06-15.jsonl", isDirectory: false }, - ]); + mockReadDir.mockResolvedValueOnce([{ name: "2025-06-15.jsonl", isDirectory: false }]); mockReadTextFile.mockRejectedValueOnce(new Error("corrupt file")); const result = await readAllPersistedLogs(); @@ -735,8 +712,7 @@ describe("log persistence", () => { describe("activeFlush tracking", () => { it("clears activeFlush after successful flush", async () => { const { getListener } = captureListener(); - const { initLogPersistence, clearPendingPersistedLogs } = - await freshImport(); + const { initLogPersistence, clearPendingPersistedLogs } = await freshImport(); await initLogPersistence(); getListener()!(makeEntry()); @@ -751,8 +727,7 @@ describe("log persistence", () => { it("clears activeFlush after failed flush", async () => { mockWriteTextFile.mockRejectedValueOnce(new Error("write error")); const { getListener } = captureListener(); - const { initLogPersistence, clearPendingPersistedLogs } = - await freshImport(); + const { initLogPersistence, clearPendingPersistedLogs } = await freshImport(); await initLogPersistence(); getListener()!(makeEntry()); @@ -807,8 +782,7 @@ describe("log persistence", () => { it("handles entries with undefined name in readAllPersistedLogs", async () => { captureListener(); - const { initLogPersistence, readAllPersistedLogs } = - await freshImport(); + const { initLogPersistence, readAllPersistedLogs } = await freshImport(); await initLogPersistence(); mockReadDir.mockResolvedValueOnce([ @@ -849,9 +823,7 @@ describe("log persistence", () => { getListener()!(makeEntry()); await vi.advanceTimersByTimeAsync(2000); - expect(mockWriteTextFile.mock.calls[0]![0]).toBe( - "/mock/logs/client-logs/2024-01-01.jsonl", - ); + expect(mockWriteTextFile.mock.calls[0]![0]).toBe("/mock/logs/client-logs/2024-01-01.jsonl"); }); it("cleanup final flush catches and logs errors", async () => { diff --git a/Client/tauri-client/tests/unit/logger.test.ts b/Client/tauri-client/tests/unit/logger.test.ts index 720257af..f5b18370 100644 --- a/Client/tauri-client/tests/unit/logger.test.ts +++ b/Client/tauri-client/tests/unit/logger.test.ts @@ -61,11 +61,7 @@ describe("logger", () => { const log = createLogger("test"); log.info("with data", { key: "value" }); - expect(infoSpy).toHaveBeenCalledWith( - expect.any(String), - "with data", - { key: "value" }, - ); + expect(infoSpy).toHaveBeenCalledWith(expect.any(String), "with data", { key: "value" }); }); it("notifies listeners", () => { @@ -111,9 +107,7 @@ describe("logger", () => { expect(listener).toHaveBeenCalledTimes(1); const entry = listener.mock.calls[0]?.[0]; - expect(entry.data).toEqual( - expect.objectContaining({ error: "something broke" }), - ); + expect(entry.data).toEqual(expect.objectContaining({ error: "something broke" })); expect(entry.data).toHaveProperty("stack"); unsub(); @@ -129,9 +123,7 @@ describe("logger", () => { log.warn("context", { reason: err, count: 3 }); const entry = listener.mock.calls[0]?.[0]; - expect(entry.data.reason).toEqual( - expect.objectContaining({ error: "inner error" }), - ); + expect(entry.data.reason).toEqual(expect.objectContaining({ error: "inner error" })); expect(entry.data.count).toBe(3); unsub(); @@ -168,10 +160,6 @@ describe("logger", () => { log.info("no data"); // The third argument should be "" (empty string fallback) - expect(infoSpy).toHaveBeenCalledWith( - expect.any(String), - "no data", - "", - ); + expect(infoSpy).toHaveBeenCalledWith(expect.any(String), "no data", ""); }); }); diff --git a/Client/tauri-client/tests/unit/logs-tab.test.ts b/Client/tauri-client/tests/unit/logs-tab.test.ts index b4f81662..0bbf3359 100644 --- a/Client/tauri-client/tests/unit/logs-tab.test.ts +++ b/Client/tauri-client/tests/unit/logs-tab.test.ts @@ -6,7 +6,7 @@ const { mockClearLogBuffer, mockAddLogListener, mockSetLogLevel, -// eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any } = vi.hoisted(() => ({ mockGetLogBuffer: vi.fn(), mockClearLogBuffer: vi.fn(), @@ -325,7 +325,9 @@ describe("LogsTab", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any mockAddLogListener.mockImplementation(((cb: any) => { logCallback = cb; - return () => { logCallback = undefined; }; + return () => { + logCallback = undefined; + }; }) as any); const handle = createLogsTab(() => "Logs" as TabName, controller.signal); @@ -345,7 +347,9 @@ describe("LogsTab", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any mockAddLogListener.mockImplementation(((cb: any) => { logCallback = cb; - return () => { logCallback = undefined; }; + return () => { + logCallback = undefined; + }; }) as any); const handle = createLogsTab(() => "Account" as TabName, controller.signal); diff --git a/Client/tauri-client/tests/unit/media-cache.test.ts b/Client/tauri-client/tests/unit/media-cache.test.ts index cd4479a7..cebb8e9d 100644 --- a/Client/tauri-client/tests/unit/media-cache.test.ts +++ b/Client/tauri-client/tests/unit/media-cache.test.ts @@ -38,9 +38,12 @@ describe("media cache clearing", () => { it("replaces a stale loading title with a fallback when the cache is cleared mid-fetch", async () => { let resolveFetch: ((value: ReturnType) => void) | undefined; - fetchMock.mockImplementationOnce(() => new Promise((resolve) => { - resolveFetch = resolve; - })); + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); const element = renderYouTubeEmbed("abc123", "https://www.youtube.com/watch?v=abc123"); document.body.appendChild(element); @@ -55,4 +58,4 @@ describe("media cache clearing", () => { expect(title.textContent).toBe("YouTube Video"); }); }); -}); \ No newline at end of file +}); diff --git a/Client/tauri-client/tests/unit/media-visibility.test.ts b/Client/tauri-client/tests/unit/media-visibility.test.ts index dd3f48fb..b4430058 100644 --- a/Client/tauri-client/tests/unit/media-visibility.test.ts +++ b/Client/tauri-client/tests/unit/media-visibility.test.ts @@ -1,11 +1,11 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { observeMedia, unobserveMedia, pauseAllMedia, resumeVisibleMedia, destroyObserver, -} from '../../src/lib/media-visibility'; +} from "../../src/lib/media-visibility"; // Mock IntersectionObserver let observerCallback: IntersectionObserverCallback; @@ -15,7 +15,7 @@ const disconnectMock = vi.fn(); class MockIntersectionObserver implements IntersectionObserver { readonly root: Element | null = null; - readonly rootMargin: string = '0px'; + readonly rootMargin: string = "0px"; readonly thresholds: readonly number[] = [0]; constructor(callback: IntersectionObserverCallback) { observerCallback = callback; @@ -23,19 +23,21 @@ class MockIntersectionObserver implements IntersectionObserver { observe = observeMock; unobserve = unobserveMock; disconnect = disconnectMock; - takeRecords(): IntersectionObserverEntry[] { return []; } + takeRecords(): IntersectionObserverEntry[] { + return []; + } } function createFakeImg(src: string): HTMLImageElement { - const img = document.createElement('img'); + const img = document.createElement("img"); img.src = src; - Object.defineProperty(img, 'naturalWidth', { value: 100 }); - Object.defineProperty(img, 'naturalHeight', { value: 100 }); + Object.defineProperty(img, "naturalWidth", { value: 100 }); + Object.defineProperty(img, "naturalHeight", { value: 100 }); return img; } function createWrapper(): HTMLDivElement { - return document.createElement('div'); + return document.createElement("div"); } function fireIntersection(entries: Array<{ target: Element; isIntersecting: boolean }>): void { @@ -52,20 +54,20 @@ function fireIntersection(entries: Array<{ target: Element; isIntersecting: bool } function setupCanvasMocks(): () => void { - const mockCanvas = document.createElement('canvas'); + const mockCanvas = document.createElement("canvas"); const mockCtx = { drawImage: vi.fn() }; const origCreateElement = document.createElement.bind(document); - vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { - if (tag === 'canvas') return mockCanvas; + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + if (tag === "canvas") return mockCanvas; return origCreateElement(tag); }); - vi.spyOn(mockCanvas, 'getContext').mockReturnValue(mockCtx as any); - vi.spyOn(mockCanvas, 'toDataURL').mockReturnValue('data:image/png;base64,frozen'); + vi.spyOn(mockCanvas, "getContext").mockReturnValue(mockCtx as any); + vi.spyOn(mockCanvas, "toDataURL").mockReturnValue("data:image/png;base64,frozen"); return () => vi.restoreAllMocks(); } beforeEach(() => { - vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); vi.useFakeTimers(); observeMock.mockClear(); unobserveMock.mockClear(); @@ -78,210 +80,210 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe('media-visibility', () => { - it('observeMedia registers image with IntersectionObserver', () => { - const img = createFakeImg('https://example.com/cat.gif'); +describe("media-visibility", () => { + it("observeMedia registers image with IntersectionObserver", () => { + const img = createFakeImg("https://example.com/cat.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/cat.gif', wrap); + observeMedia(img, "https://example.com/cat.gif", wrap); expect(observeMock).toHaveBeenCalledWith(img); }); - it('adds play/pause button to wrapper', () => { - const img = createFakeImg('https://example.com/cat.gif'); + it("adds play/pause button to wrapper", () => { + const img = createFakeImg("https://example.com/cat.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/cat.gif', wrap); - const btn = wrap.querySelector('.gif-play-btn'); + observeMedia(img, "https://example.com/cat.gif", wrap); + const btn = wrap.querySelector(".gif-play-btn"); expect(btn).not.toBeNull(); }); - it('unobserveMedia stops observing and restores original src', () => { - const img = createFakeImg('https://example.com/cat.gif'); + it("unobserveMedia stops observing and restores original src", () => { + const img = createFakeImg("https://example.com/cat.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/cat.gif', wrap); - img.src = 'data:image/png;base64,frozen'; + observeMedia(img, "https://example.com/cat.gif", wrap); + img.src = "data:image/png;base64,frozen"; unobserveMedia(img); expect(unobserveMock).toHaveBeenCalledWith(img); - expect(img.src).toBe('https://example.com/cat.gif'); + expect(img.src).toBe("https://example.com/cat.gif"); }); - it('does not double-observe same image', () => { - const img = createFakeImg('https://example.com/cat.gif'); + it("does not double-observe same image", () => { + const img = createFakeImg("https://example.com/cat.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/cat.gif', wrap); - observeMedia(img, 'https://example.com/cat.gif', wrap); + observeMedia(img, "https://example.com/cat.gif", wrap); + observeMedia(img, "https://example.com/cat.gif", wrap); expect(observeMock).toHaveBeenCalledTimes(1); }); - it('freezes GIF when it leaves viewport', () => { + it("freezes GIF when it leaves viewport", () => { const cleanup = setupCanvasMocks(); - const img = createFakeImg('https://example.com/cat.gif'); + const img = createFakeImg("https://example.com/cat.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/cat.gif', wrap); + observeMedia(img, "https://example.com/cat.gif", wrap); fireIntersection([{ target: img, isIntersecting: false }]); - expect(img.src).toBe('data:image/png;base64,frozen'); + expect(img.src).toBe("data:image/png;base64,frozen"); cleanup(); }); - it('auto-pauses after 10 seconds', () => { + it("auto-pauses after 10 seconds", () => { const cleanup = setupCanvasMocks(); - const img = createFakeImg('https://example.com/cat.gif'); + const img = createFakeImg("https://example.com/cat.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/cat.gif', wrap); - expect(img.src).toBe('https://example.com/cat.gif'); + observeMedia(img, "https://example.com/cat.gif", wrap); + expect(img.src).toBe("https://example.com/cat.gif"); vi.advanceTimersByTime(10_000); - expect(img.src).toBe('data:image/png;base64,frozen'); - const btn = wrap.querySelector('.gif-play-btn'); + expect(img.src).toBe("data:image/png;base64,frozen"); + const btn = wrap.querySelector(".gif-play-btn"); expect(btn?.querySelector('svg[data-icon="play"]')).not.toBeNull(); cleanup(); }); - it('play button click unfreezes and starts new 10s timer', () => { + it("play button click unfreezes and starts new 10s timer", () => { const cleanup = setupCanvasMocks(); - const img = createFakeImg('https://example.com/cat.gif'); + const img = createFakeImg("https://example.com/cat.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/cat.gif', wrap); + observeMedia(img, "https://example.com/cat.gif", wrap); vi.advanceTimersByTime(10_000); - expect(img.src).toBe('data:image/png;base64,frozen'); - const btn = wrap.querySelector('.gif-play-btn') as HTMLButtonElement; + expect(img.src).toBe("data:image/png;base64,frozen"); + const btn = wrap.querySelector(".gif-play-btn") as HTMLButtonElement; btn.click(); - expect(img.src).toBe('https://example.com/cat.gif'); + expect(img.src).toBe("https://example.com/cat.gif"); vi.advanceTimersByTime(10_000); - expect(img.src).toBe('data:image/png;base64,frozen'); + expect(img.src).toBe("data:image/png;base64,frozen"); cleanup(); }); - it('pause button click freezes immediately', () => { + it("pause button click freezes immediately", () => { const cleanup = setupCanvasMocks(); - const img = createFakeImg('https://example.com/cat.gif'); + const img = createFakeImg("https://example.com/cat.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/cat.gif', wrap); - expect(img.src).toBe('https://example.com/cat.gif'); - const btn = wrap.querySelector('.gif-play-btn') as HTMLButtonElement; + observeMedia(img, "https://example.com/cat.gif", wrap); + expect(img.src).toBe("https://example.com/cat.gif"); + const btn = wrap.querySelector(".gif-play-btn") as HTMLButtonElement; btn.click(); - expect(img.src).toBe('data:image/png;base64,frozen'); + expect(img.src).toBe("data:image/png;base64,frozen"); cleanup(); }); - it('pauseAllMedia freezes all tracked GIFs', () => { + it("pauseAllMedia freezes all tracked GIFs", () => { const cleanup = setupCanvasMocks(); - const img1 = createFakeImg('https://example.com/a.gif'); - const img2 = createFakeImg('https://example.com/b.gif'); + const img1 = createFakeImg("https://example.com/a.gif"); + const img2 = createFakeImg("https://example.com/b.gif"); const wrap1 = createWrapper(); const wrap2 = createWrapper(); - observeMedia(img1, 'https://example.com/a.gif', wrap1); - observeMedia(img2, 'https://example.com/b.gif', wrap2); + observeMedia(img1, "https://example.com/a.gif", wrap1); + observeMedia(img2, "https://example.com/b.gif", wrap2); pauseAllMedia(); - expect(img1.src).toBe('data:image/png;base64,frozen'); - expect(img2.src).toBe('data:image/png;base64,frozen'); + expect(img1.src).toBe("data:image/png;base64,frozen"); + expect(img2.src).toBe("data:image/png;base64,frozen"); cleanup(); }); - it('resumeVisibleMedia only unfreezes intersecting GIFs', () => { - const img1 = createFakeImg('https://example.com/a.gif'); - const img2 = createFakeImg('https://example.com/b.gif'); + it("resumeVisibleMedia only unfreezes intersecting GIFs", () => { + const img1 = createFakeImg("https://example.com/a.gif"); + const img2 = createFakeImg("https://example.com/b.gif"); const wrap1 = createWrapper(); const wrap2 = createWrapper(); - observeMedia(img1, 'https://example.com/a.gif', wrap1); - observeMedia(img2, 'https://example.com/b.gif', wrap2); + observeMedia(img1, "https://example.com/a.gif", wrap1); + observeMedia(img2, "https://example.com/b.gif", wrap2); fireIntersection([ { target: img1, isIntersecting: true }, { target: img2, isIntersecting: false }, ]); - img1.src = 'data:image/png;base64,frozen'; - img2.src = 'data:image/png;base64,frozen'; + img1.src = "data:image/png;base64,frozen"; + img2.src = "data:image/png;base64,frozen"; resumeVisibleMedia(); - expect(img1.src).toBe('https://example.com/a.gif'); - expect(img2.src).toBe('data:image/png;base64,frozen'); + expect(img1.src).toBe("https://example.com/a.gif"); + expect(img2.src).toBe("data:image/png;base64,frozen"); }); - it('wrapper gets gif-paused class when frozen', () => { + it("wrapper gets gif-paused class when frozen", () => { const cleanup = setupCanvasMocks(); - const img = createFakeImg('https://example.com/cat.gif'); + const img = createFakeImg("https://example.com/cat.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/cat.gif', wrap); - expect(wrap.classList.contains('gif-paused')).toBe(false); + observeMedia(img, "https://example.com/cat.gif", wrap); + expect(wrap.classList.contains("gif-paused")).toBe(false); vi.advanceTimersByTime(10_000); - expect(wrap.classList.contains('gif-paused')).toBe(true); + expect(wrap.classList.contains("gif-paused")).toBe(true); cleanup(); }); - it('destroyObserver cleans up', () => { - const img = createFakeImg('https://example.com/cat.gif'); + it("destroyObserver cleans up", () => { + const img = createFakeImg("https://example.com/cat.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/cat.gif', wrap); + observeMedia(img, "https://example.com/cat.gif", wrap); destroyObserver(); expect(disconnectMock).toHaveBeenCalled(); }); - it('unobserveMedia does not start a dangling auto-timer', () => { - const img = createFakeImg('https://example.com/cat.gif'); + it("unobserveMedia does not start a dangling auto-timer", () => { + const img = createFakeImg("https://example.com/cat.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/cat.gif', wrap); + observeMedia(img, "https://example.com/cat.gif", wrap); unobserveMedia(img); const cleanup = setupCanvasMocks(); vi.advanceTimersByTime(15_000); // toDataURL should NOT have been called (no dangling timer) - const canvas = document.createElement('canvas'); + const canvas = document.createElement("canvas"); expect(canvas.toDataURL).not.toHaveBeenCalled(); cleanup(); }); - it('starts frozen when startFrozen is true', () => { + it("starts frozen when startFrozen is true", () => { const cleanup = setupCanvasMocks(); - const img = createFakeImg('https://example.com/cat.gif'); + const img = createFakeImg("https://example.com/cat.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/cat.gif', wrap, true); + observeMedia(img, "https://example.com/cat.gif", wrap, true); // Should be frozen immediately - expect(wrap.classList.contains('gif-paused')).toBe(true); + expect(wrap.classList.contains("gif-paused")).toBe(true); // The button should show play icon - const btn = wrap.querySelector('.gif-play-btn'); + const btn = wrap.querySelector(".gif-play-btn"); expect(btn?.querySelector('svg[data-icon="play"]')).not.toBeNull(); cleanup(); }); - it('startFrozen image can be unfrozen by clicking play', () => { + it("startFrozen image can be unfrozen by clicking play", () => { const cleanup = setupCanvasMocks(); - const img = createFakeImg('https://example.com/cat.gif'); + const img = createFakeImg("https://example.com/cat.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/cat.gif', wrap, true); + observeMedia(img, "https://example.com/cat.gif", wrap, true); - expect(wrap.classList.contains('gif-paused')).toBe(true); + expect(wrap.classList.contains("gif-paused")).toBe(true); // Click play to unfreeze - const btn = wrap.querySelector('.gif-play-btn') as HTMLButtonElement; + const btn = wrap.querySelector(".gif-play-btn") as HTMLButtonElement; btn.click(); - expect(img.src).toBe('https://example.com/cat.gif'); - expect(wrap.classList.contains('gif-paused')).toBe(false); + expect(img.src).toBe("https://example.com/cat.gif"); + expect(wrap.classList.contains("gif-paused")).toBe(false); cleanup(); }); - it('unobserveMedia on an untracked image is a no-op', () => { - const img = createFakeImg('https://example.com/unknown.gif'); + it("unobserveMedia on an untracked image is a no-op", () => { + const img = createFakeImg("https://example.com/unknown.gif"); // Should not throw unobserveMedia(img); expect(unobserveMock).not.toHaveBeenCalled(); }); - it('pauseAllMedia cleans up stale WeakRefs', () => { + it("pauseAllMedia cleans up stale WeakRefs", () => { const cleanup = setupCanvasMocks(); - const img = createFakeImg('https://example.com/stale.gif'); + const img = createFakeImg("https://example.com/stale.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/stale.gif', wrap); + observeMedia(img, "https://example.com/stale.gif", wrap); // First, unobserve to remove from tracked but leave in allTracked // Then pauseAllMedia should handle the missing entry gracefully pauseAllMedia(); // Should not throw - expect(img.src).toBe('data:image/png;base64,frozen'); + expect(img.src).toBe("data:image/png;base64,frozen"); cleanup(); }); - it('resumeVisibleMedia cleans up stale WeakRefs without throwing', () => { + it("resumeVisibleMedia cleans up stale WeakRefs without throwing", () => { // Register and immediately unobserve so the entry is gone from tracked - const img = createFakeImg('https://example.com/gone.gif'); + const img = createFakeImg("https://example.com/gone.gif"); const wrap = createWrapper(); - observeMedia(img, 'https://example.com/gone.gif', wrap); + observeMedia(img, "https://example.com/gone.gif", wrap); unobserveMedia(img); // Now resumeVisibleMedia should not crash resumeVisibleMedia(); diff --git a/Client/tauri-client/tests/unit/media.test.ts b/Client/tauri-client/tests/unit/media.test.ts index 6b770287..4582e6de 100644 --- a/Client/tauri-client/tests/unit/media.test.ts +++ b/Client/tauri-client/tests/unit/media.test.ts @@ -108,7 +108,10 @@ function fireImgError(parent: HTMLElement): void { } /** Create a MouseEvent with specified client coordinates. */ -function mouseEvent(type: string, opts: { clientX?: number; clientY?: number; deltaY?: number } = {}): MouseEvent { +function mouseEvent( + type: string, + opts: { clientX?: number; clientY?: number; deltaY?: number } = {}, +): MouseEvent { return new MouseEvent(type, { bubbles: true, cancelable: true, @@ -204,12 +207,9 @@ describe("media.ts", () => { // ========================================================================= describe("isDirectImageUrl", () => { - it.each([".gif", ".png", ".jpg", ".jpeg", ".webp"])( - "returns true for %s extension", - (ext) => { - expect(isDirectImageUrl(`https://example.com/photo${ext}`)).toBe(true); - }, - ); + it.each([".gif", ".png", ".jpg", ".jpeg", ".webp"])("returns true for %s extension", (ext) => { + expect(isDirectImageUrl(`https://example.com/photo${ext}`)).toBe(true); + }); it("returns true for uppercase extensions", () => { expect(isDirectImageUrl("https://example.com/PHOTO.PNG")).toBe(true); @@ -574,9 +574,7 @@ describe("media.ts", () => { await vi.waitFor(() => { // Last video should have its title - const last = document.body.querySelector( - `[href="https://www.youtube.com/watch?v=vid200"]`, - ); + const last = document.body.querySelector(`[href="https://www.youtube.com/watch?v=vid200"]`); expect(last?.textContent).toBe("Title 200"); }); }); @@ -609,7 +607,10 @@ describe("media.ts", () => { it("uses fallback title when cache generation changes during successful fetch", async () => { let resolveFetch: ((value: ReturnType) => void) | null = null; fetchMock.mockImplementationOnce( - () => new Promise((resolve) => { resolveFetch = resolve; }), + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), ); const embed = renderYouTubeEmbed("gen1", "https://www.youtube.com/watch?v=gen1"); @@ -632,7 +633,10 @@ describe("media.ts", () => { it("uses fallback title when cache generation changes during failed fetch", async () => { let rejectFetch: ((reason: Error) => void) | null = null; fetchMock.mockImplementationOnce( - () => new Promise((_resolve, reject) => { rejectFetch = reject; }), + () => + new Promise((_resolve, reject) => { + rejectFetch = reject; + }), ); const embed = renderYouTubeEmbed("gen2", "https://www.youtube.com/watch?v=gen2"); @@ -811,18 +815,22 @@ describe("media.ts", () => { }); // Simulate mousedown then click at same position (no drag) - img.dispatchEvent(new MouseEvent("mousedown", { - clientX: 200, - clientY: 150, - bubbles: true, - cancelable: true, - })); - img.dispatchEvent(new MouseEvent("click", { - clientX: 200, - clientY: 150, - bubbles: true, - cancelable: true, - })); + img.dispatchEvent( + new MouseEvent("mousedown", { + clientX: 200, + clientY: 150, + bubbles: true, + cancelable: true, + }), + ); + img.dispatchEvent( + new MouseEvent("click", { + clientX: 200, + clientY: 150, + bubbles: true, + cancelable: true, + }), + ); // Should zoom to scale 3 const scaleMatch = img.style.transform.match(/scale\(([^)]+)\)/); @@ -836,17 +844,42 @@ describe("media.ts", () => { const img = document.body.querySelector(".image-lightbox img") as HTMLElement; vi.spyOn(img, "getBoundingClientRect").mockReturnValue({ - left: 0, top: 0, width: 400, height: 300, - right: 400, bottom: 300, x: 0, y: 0, toJSON: () => ({}), + left: 0, + top: 0, + width: 400, + height: 300, + right: 400, + bottom: 300, + x: 0, + y: 0, + toJSON: () => ({}), }); // Zoom in first - img.dispatchEvent(new MouseEvent("mousedown", { clientX: 200, clientY: 150, bubbles: true, cancelable: true })); - img.dispatchEvent(new MouseEvent("click", { clientX: 200, clientY: 150, bubbles: true, cancelable: true })); + img.dispatchEvent( + new MouseEvent("mousedown", { + clientX: 200, + clientY: 150, + bubbles: true, + cancelable: true, + }), + ); + img.dispatchEvent( + new MouseEvent("click", { clientX: 200, clientY: 150, bubbles: true, cancelable: true }), + ); // Now click again to zoom out (scale > 1.1 so it resets) - img.dispatchEvent(new MouseEvent("mousedown", { clientX: 200, clientY: 150, bubbles: true, cancelable: true })); - img.dispatchEvent(new MouseEvent("click", { clientX: 200, clientY: 150, bubbles: true, cancelable: true })); + img.dispatchEvent( + new MouseEvent("mousedown", { + clientX: 200, + clientY: 150, + bubbles: true, + cancelable: true, + }), + ); + img.dispatchEvent( + new MouseEvent("click", { clientX: 200, clientY: 150, bubbles: true, cancelable: true }), + ); expect(img.style.transform).toContain("scale(1)"); }); @@ -857,8 +890,17 @@ describe("media.ts", () => { const img = document.body.querySelector(".image-lightbox img") as HTMLElement; // Mousedown at one position, click at another (moved > 5px) - img.dispatchEvent(new MouseEvent("mousedown", { clientX: 100, clientY: 100, bubbles: true, cancelable: true })); - img.dispatchEvent(new MouseEvent("click", { clientX: 120, clientY: 100, bubbles: true, cancelable: true })); + img.dispatchEvent( + new MouseEvent("mousedown", { + clientX: 100, + clientY: 100, + bubbles: true, + cancelable: true, + }), + ); + img.dispatchEvent( + new MouseEvent("click", { clientX: 120, clientY: 100, bubbles: true, cancelable: true }), + ); // Should remain at scale(1) because dx=20 > 5 expect(img.style.transform).toBe(""); @@ -871,20 +913,45 @@ describe("media.ts", () => { const overlay = document.body.querySelector(".image-lightbox") as HTMLElement; vi.spyOn(img, "getBoundingClientRect").mockReturnValue({ - left: 0, top: 0, width: 400, height: 300, - right: 400, bottom: 300, x: 0, y: 0, toJSON: () => ({}), + left: 0, + top: 0, + width: 400, + height: 300, + right: 400, + bottom: 300, + x: 0, + y: 0, + toJSON: () => ({}), }); // Zoom in first - img.dispatchEvent(new MouseEvent("mousedown", { clientX: 200, clientY: 150, bubbles: true, cancelable: true })); - img.dispatchEvent(new MouseEvent("click", { clientX: 200, clientY: 150, bubbles: true, cancelable: true })); + img.dispatchEvent( + new MouseEvent("mousedown", { + clientX: 200, + clientY: 150, + bubbles: true, + cancelable: true, + }), + ); + img.dispatchEvent( + new MouseEvent("click", { clientX: 200, clientY: 150, bubbles: true, cancelable: true }), + ); // Now start panning (mousedown while zoomed) - img.dispatchEvent(new MouseEvent("mousedown", { clientX: 200, clientY: 150, bubbles: true, cancelable: true })); + img.dispatchEvent( + new MouseEvent("mousedown", { + clientX: 200, + clientY: 150, + bubbles: true, + cancelable: true, + }), + ); expect(overlay.classList.contains("dragging")).toBe(true); // Move mouse - document.dispatchEvent(new MouseEvent("mousemove", { clientX: 250, clientY: 200, bubbles: true })); + document.dispatchEvent( + new MouseEvent("mousemove", { clientX: 250, clientY: 200, bubbles: true }), + ); // Transform should reflect pan offset expect(img.style.transform).toContain("translate("); @@ -908,7 +975,9 @@ describe("media.ts", () => { const img = document.body.querySelector(".image-lightbox img") as HTMLElement; // mousemove without prior drag should not alter transform - document.dispatchEvent(new MouseEvent("mousemove", { clientX: 300, clientY: 300, bubbles: true })); + document.dispatchEvent( + new MouseEvent("mousemove", { clientX: 300, clientY: 300, bubbles: true }), + ); expect(img.style.transform).toBe(""); }); @@ -919,8 +988,15 @@ describe("media.ts", () => { const imgWrap = document.body.querySelector(".image-lightbox-wrap") as HTMLElement; vi.spyOn(img, "getBoundingClientRect").mockReturnValue({ - left: 0, top: 0, width: 400, height: 300, - right: 400, bottom: 300, x: 0, y: 0, toJSON: () => ({}), + left: 0, + top: 0, + width: 400, + height: 300, + right: 400, + bottom: 300, + x: 0, + y: 0, + toJSON: () => ({}), }); // Scroll up (negative deltaY = zoom in) @@ -945,8 +1021,15 @@ describe("media.ts", () => { const imgWrap = document.body.querySelector(".image-lightbox-wrap") as HTMLElement; vi.spyOn(img, "getBoundingClientRect").mockReturnValue({ - left: 0, top: 0, width: 400, height: 300, - right: 400, bottom: 300, x: 0, y: 0, toJSON: () => ({}), + left: 0, + top: 0, + width: 400, + height: 300, + right: 400, + bottom: 300, + x: 0, + y: 0, + toJSON: () => ({}), }); // Scroll down (positive deltaY = zoom out) @@ -971,15 +1054,28 @@ describe("media.ts", () => { const imgWrap = document.body.querySelector(".image-lightbox-wrap") as HTMLElement; vi.spyOn(img, "getBoundingClientRect").mockReturnValue({ - left: 0, top: 0, width: 400, height: 300, - right: 400, bottom: 300, x: 0, y: 0, toJSON: () => ({}), + left: 0, + top: 0, + width: 400, + height: 300, + right: 400, + bottom: 300, + x: 0, + y: 0, + toJSON: () => ({}), }); // Zoom out many times for (let i = 0; i < 20; i++) { - imgWrap.dispatchEvent(new WheelEvent("wheel", { - deltaY: 100, clientX: 200, clientY: 150, bubbles: true, cancelable: true, - })); + imgWrap.dispatchEvent( + new WheelEvent("wheel", { + deltaY: 100, + clientX: 200, + clientY: 150, + bubbles: true, + cancelable: true, + }), + ); } const scaleMatch = img.style.transform.match(/scale\(([^)]+)\)/); @@ -993,15 +1089,28 @@ describe("media.ts", () => { const imgWrap = document.body.querySelector(".image-lightbox-wrap") as HTMLElement; vi.spyOn(img, "getBoundingClientRect").mockReturnValue({ - left: 0, top: 0, width: 400, height: 300, - right: 400, bottom: 300, x: 0, y: 0, toJSON: () => ({}), + left: 0, + top: 0, + width: 400, + height: 300, + right: 400, + bottom: 300, + x: 0, + y: 0, + toJSON: () => ({}), }); // Zoom in many times for (let i = 0; i < 50; i++) { - imgWrap.dispatchEvent(new WheelEvent("wheel", { - deltaY: -100, clientX: 200, clientY: 150, bubbles: true, cancelable: true, - })); + imgWrap.dispatchEvent( + new WheelEvent("wheel", { + deltaY: -100, + clientX: 200, + clientY: 150, + bubbles: true, + cancelable: true, + }), + ); } const scaleMatch = img.style.transform.match(/scale\(([^)]+)\)/); @@ -1053,7 +1162,9 @@ describe("media.ts", () => { // After close, key events should not error or affect anything document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); - document.dispatchEvent(new MouseEvent("mousemove", { clientX: 100, clientY: 100, bubbles: true })); + document.dispatchEvent( + new MouseEvent("mousemove", { clientX: 100, clientY: 100, bubbles: true }), + ); document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); }); }); diff --git a/Client/tauri-client/tests/unit/member-list.test.ts b/Client/tauri-client/tests/unit/member-list.test.ts index 9a958e68..248054c5 100644 --- a/Client/tauri-client/tests/unit/member-list.test.ts +++ b/Client/tauri-client/tests/unit/member-list.test.ts @@ -195,7 +195,9 @@ describe("MemberList", () => { it("skips role groups that have no members", () => { // Only add an owner — other groups should not render - setTestMembers([makeMember({ id: 1, username: "Alice", role: "owner", status: "online" as UserStatus })]); + setTestMembers([ + makeMember({ id: 1, username: "Alice", role: "owner", status: "online" as UserStatus }), + ]); memberList.mount(container); const headers = container.querySelectorAll(".member-role-group"); @@ -314,7 +316,9 @@ describe("MemberList", () => { expect(container.querySelectorAll(".member-item").length).toBe(6); // Remove all but one member - setTestMembers([makeMember({ id: 99, username: "Solo", role: "member", status: "online" as UserStatus })]); + setTestMembers([ + makeMember({ id: 99, username: "Solo", role: "member", status: "online" as UserStatus }), + ]); membersStore.flush(); expect(container.querySelectorAll(".member-item").length).toBe(1); diff --git a/Client/tauri-client/tests/unit/member-picker-modal.test.ts b/Client/tauri-client/tests/unit/member-picker-modal.test.ts index 41b0acbf..3eb9ff1f 100644 --- a/Client/tauri-client/tests/unit/member-picker-modal.test.ts +++ b/Client/tauri-client/tests/unit/member-picker-modal.test.ts @@ -197,9 +197,7 @@ describe("createMemberPickerModal", () => { it("shows empty list when only the current user exists", () => { setCurrentUser(1); - setStoreMembers([ - makeMember({ id: 1, username: "Me" }), - ]); + setStoreMembers([makeMember({ id: 1, username: "Me" })]); const component = createMemberPickerModal({ onSelect: vi.fn(), @@ -233,9 +231,7 @@ describe("createMemberPickerModal", () => { it("renders avatar with uppercased first letter of username", () => { setCurrentUser(1); - setStoreMembers([ - makeMember({ id: 2, username: "alice" }), - ]); + setStoreMembers([makeMember({ id: 2, username: "alice" })]); const component = createMemberPickerModal({ onSelect: vi.fn(), @@ -252,9 +248,7 @@ describe("createMemberPickerModal", () => { it("renders username text in the item", () => { setCurrentUser(1); - setStoreMembers([ - makeMember({ id: 2, username: "Bob" }), - ]); + setStoreMembers([makeMember({ id: 2, username: "Bob" })]); const component = createMemberPickerModal({ onSelect: vi.fn(), @@ -272,9 +266,7 @@ describe("createMemberPickerModal", () => { it("renders member status text", () => { setCurrentUser(1); - setStoreMembers([ - makeMember({ id: 2, username: "Alice", status: "online" }), - ]); + setStoreMembers([makeMember({ id: 2, username: "Alice", status: "online" })]); const component = createMemberPickerModal({ onSelect: vi.fn(), @@ -290,9 +282,7 @@ describe("createMemberPickerModal", () => { it("renders offline status for offline members", () => { setCurrentUser(1); - setStoreMembers([ - makeMember({ id: 2, username: "Alice", status: "offline" }), - ]); + setStoreMembers([makeMember({ id: 2, username: "Alice", status: "offline" })]); const component = createMemberPickerModal({ onSelect: vi.fn(), @@ -308,9 +298,7 @@ describe("createMemberPickerModal", () => { it("applies green color for online status", () => { setCurrentUser(1); - setStoreMembers([ - makeMember({ id: 2, username: "Alice", status: "online" }), - ]); + setStoreMembers([makeMember({ id: 2, username: "Alice", status: "online" })]); const component = createMemberPickerModal({ onSelect: vi.fn(), @@ -329,9 +317,7 @@ describe("createMemberPickerModal", () => { it("applies micro text color for non-online status", () => { setCurrentUser(1); - setStoreMembers([ - makeMember({ id: 2, username: "Alice", status: "idle" }), - ]); + setStoreMembers([makeMember({ id: 2, username: "Alice", status: "idle" })]); const component = createMemberPickerModal({ onSelect: vi.fn(), @@ -351,9 +337,7 @@ describe("createMemberPickerModal", () => { it("calls onSelect with the member's user ID when a member item is clicked", () => { setCurrentUser(1); - setStoreMembers([ - makeMember({ id: 42, username: "Alice" }), - ]); + setStoreMembers([makeMember({ id: 42, username: "Alice" })]); const onSelect = vi.fn(); const component = createMemberPickerModal({ @@ -373,9 +357,7 @@ describe("createMemberPickerModal", () => { it("closes the modal when a member is selected", () => { setCurrentUser(1); - setStoreMembers([ - makeMember({ id: 2, username: "Alice" }), - ]); + setStoreMembers([makeMember({ id: 2, username: "Alice" })]); const component = createMemberPickerModal({ onSelect: vi.fn(), @@ -604,9 +586,7 @@ describe("createMemberPickerModal", () => { it("each member item has the channel-item class", () => { setCurrentUser(1); - setStoreMembers([ - makeMember({ id: 2, username: "Alice" }), - ]); + setStoreMembers([makeMember({ id: 2, username: "Alice" })]); const component = createMemberPickerModal({ onSelect: vi.fn(), @@ -622,9 +602,7 @@ describe("createMemberPickerModal", () => { it("each member item has cursor pointer style", () => { setCurrentUser(1); - setStoreMembers([ - makeMember({ id: 2, username: "Alice" }), - ]); + setStoreMembers([makeMember({ id: 2, username: "Alice" })]); const component = createMemberPickerModal({ onSelect: vi.fn(), diff --git a/Client/tauri-client/tests/unit/message-controller.test.ts b/Client/tauri-client/tests/unit/message-controller.test.ts index 2e4035ea..6afb9ea9 100644 --- a/Client/tauri-client/tests/unit/message-controller.test.ts +++ b/Client/tauri-client/tests/unit/message-controller.test.ts @@ -80,11 +80,7 @@ describe("createMessageController", () => { await ctrl.loadMessages(42, signal); expect(api.getMessages).toHaveBeenCalledWith(42, { limit: 50 }, signal); - expect(mockSetMessages).toHaveBeenCalledWith( - 42, - [{ id: 1, content: "hi" }], - false, - ); + expect(mockSetMessages).toHaveBeenCalledWith(42, [{ id: 1, content: "hi" }], false); }); it("skips fetch when channel is already loaded", async () => { @@ -155,16 +151,8 @@ describe("createMessageController", () => { await ctrl.loadOlderMessages(42, signal); - expect(api.getMessages).toHaveBeenCalledWith( - 42, - { before: 10, limit: 50 }, - signal, - ); - expect(mockPrependMessages).toHaveBeenCalledWith( - 42, - [{ id: 5, content: "older" }], - true, - ); + expect(api.getMessages).toHaveBeenCalledWith(42, { before: 10, limit: 50 }, signal); + expect(mockPrependMessages).toHaveBeenCalledWith(42, [{ id: 5, content: "older" }], true); }); it("does nothing when channel has no messages", async () => { diff --git a/Client/tauri-client/tests/unit/message-input.test.ts b/Client/tauri-client/tests/unit/message-input.test.ts index 5ef27650..db91e22a 100644 --- a/Client/tauri-client/tests/unit/message-input.test.ts +++ b/Client/tauri-client/tests/unit/message-input.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; /** Captured emoji picker callbacks so tests can simulate selection. */ -let lastEmojiPickerOptions: { onSelect: (emoji: string) => void; onClose: () => void } | null = null; +let lastEmojiPickerOptions: { onSelect: (emoji: string) => void; onClose: () => void } | null = + null; vi.mock("@components/EmojiPicker", () => ({ createEmojiPicker: (opts: { onSelect: (emoji: string) => void; onClose: () => void }) => { @@ -24,10 +25,7 @@ vi.mock("@components/GifPicker", () => ({ }, })); -import { - createMessageInput, - type MessageInputOptions, -} from "@components/MessageInput"; +import { createMessageInput, type MessageInputOptions } from "@components/MessageInput"; function makeOptions(overrides: Partial = {}): MessageInputOptions { return { @@ -100,9 +98,7 @@ describe("MessageInput", () => { const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; textarea.value = "Enter message"; - textarea.dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), - ); + textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); expect(opts.onSend).toHaveBeenCalledWith("Enter message", null, []); @@ -333,9 +329,7 @@ describe("MessageInput", () => { comp.startEdit(88, "editing"); const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; - textarea.dispatchEvent( - new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), - ); + textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); // Edit bar should be hidden const bars = container.querySelectorAll(".reply-bar"); @@ -354,9 +348,7 @@ describe("MessageInput", () => { comp.setReplyTo(44, "replyuser"); const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; - textarea.dispatchEvent( - new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), - ); + textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); const replyBar = container.querySelector(".reply-bar") as HTMLDivElement; expect(replyBar.classList.contains("visible")).toBe(false); @@ -377,9 +369,7 @@ describe("MessageInput", () => { const listener = vi.fn(); container.addEventListener("edit-last-message", listener); - textarea.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }), - ); + textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); expect(listener).toHaveBeenCalledTimes(1); @@ -397,9 +387,7 @@ describe("MessageInput", () => { const listener = vi.fn(); container.addEventListener("edit-last-message", listener); - textarea.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }), - ); + textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); expect(listener).not.toHaveBeenCalled(); @@ -501,7 +489,7 @@ describe("MessageInput", () => { expect(onUploadFile).not.toHaveBeenCalled(); const error = container.querySelector(".attachment-upload-error"); expect(error).not.toBeNull(); - expect(error!.textContent).toContain("Unsupported file type"); + expect(error!.textContent).toContain("is not a supported file type"); comp.destroy?.(); }); @@ -532,9 +520,12 @@ describe("MessageInput", () => { it("blocks send while uploads are still in flight", async () => { // Create an upload that never resolves during the test let resolveUpload: ((v: { id: string; url: string; filename: string }) => void) | null = null; - const onUploadFile = vi.fn(() => new Promise<{ id: string; url: string; filename: string }>((res) => { - resolveUpload = res; - })); + const onUploadFile = vi.fn( + () => + new Promise<{ id: string; url: string; filename: string }>((res) => { + resolveUpload = res; + }), + ); const opts = makeOptions({ onUploadFile }); const comp = createMessageInput(opts); comp.mount(container); @@ -567,9 +558,12 @@ describe("MessageInput", () => { it("remove button removes attachment preview while upload is pending", async () => { // Use a long-running upload so we can click remove while it's still pending let resolveUpload: ((v: { id: string; url: string; filename: string }) => void) | null = null; - const onUploadFile = vi.fn(() => new Promise<{ id: string; url: string; filename: string }>((res) => { - resolveUpload = res; - })); + const onUploadFile = vi.fn( + () => + new Promise<{ id: string; url: string; filename: string }>((res) => { + resolveUpload = res; + }), + ); const opts = makeOptions({ onUploadFile }); const comp = createMessageInput(opts); comp.mount(container); @@ -822,7 +816,11 @@ describe("MessageInput", () => { // ── Paste file handling ── it("pasting an image file triggers upload", async () => { - const onUploadFile = vi.fn(async () => ({ id: "paste-1", url: "http://x", filename: "paste.png" })); + const onUploadFile = vi.fn(async () => ({ + id: "paste-1", + url: "http://x", + filename: "paste.png", + })); const opts = makeOptions({ onUploadFile }); const comp = createMessageInput(opts); comp.mount(container); @@ -837,11 +835,13 @@ describe("MessageInput", () => { }; Object.defineProperty(pasteEvent, "clipboardData", { value: { - items: [{ - kind: "file", - type: "image/png", - getAsFile: () => file, - }], + items: [ + { + kind: "file", + type: "image/png", + getAsFile: () => file, + }, + ], }, }); @@ -854,9 +854,9 @@ describe("MessageInput", () => { comp.destroy?.(); }); - // ── Files with empty MIME type are accepted ── + // ── Files with empty MIME type are rejected (security hardening) ── - it("files with empty MIME type bypass type validation", async () => { + it("files with empty MIME type are rejected", async () => { const onUploadFile = vi.fn(async () => ({ id: "unk-1", url: "http://x", filename: "data" })); const opts = makeOptions({ onUploadFile }); const comp = createMessageInput(opts); @@ -867,9 +867,12 @@ describe("MessageInput", () => { Object.defineProperty(fileInput, "files", { value: [noTypeFile], writable: true }); fileInput.dispatchEvent(new Event("change", { bubbles: true })); - await vi.waitFor(() => { - expect(onUploadFile).toHaveBeenCalledWith(noTypeFile); - }); + await new Promise((r) => setTimeout(r, 10)); + + expect(onUploadFile).not.toHaveBeenCalled(); + const error = container.querySelector(".attachment-upload-error"); + expect(error).not.toBeNull(); + expect(error!.textContent).toContain("is not a supported file type"); comp.destroy?.(); }); diff --git a/Client/tauri-client/tests/unit/message-list.test.ts b/Client/tauri-client/tests/unit/message-list.test.ts index 67ea1853..aebd0434 100644 --- a/Client/tauri-client/tests/unit/message-list.test.ts +++ b/Client/tauri-client/tests/unit/message-list.test.ts @@ -3,9 +3,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // jsdom does not provide ResizeObserver — stub it so MessageList can mount. if (typeof globalThis.ResizeObserver === "undefined") { globalThis.ResizeObserver = class { - observe(): void { /* noop */ } - unobserve(): void { /* noop */ } - disconnect(): void { /* noop */ } + observe(): void { + /* noop */ + } + unobserve(): void { + /* noop */ + } + disconnect(): void { + /* noop */ + } } as unknown as typeof ResizeObserver; } @@ -208,7 +214,9 @@ describe("MessageList", () => { expect(icon?.textContent).toBe("@"); const text = container.querySelector(".channel-welcome-text"); - expect(text?.textContent).toBe("This is the beginning of your direct message history with Bob."); + expect(text?.textContent).toBe( + "This is the beginning of your direct message history with Bob.", + ); }); it("includes a scroll-to-bottom button", () => { @@ -265,10 +273,7 @@ describe("MessageList", () => { expect(options.onScrollTop).toHaveBeenCalledTimes(1); // Simulate new messages arriving (load-more response) - setMessages(1, [ - makeMessage({ id: 0, content: "Older message" }), - makeMessage({ id: 1 }), - ]); + setMessages(1, [makeMessage({ id: 0, content: "Older message" }), makeMessage({ id: 1 })]); messagesStore.flush(); // Now scrolling to top again should trigger onScrollTop again @@ -310,10 +315,7 @@ describe("MessageList", () => { }); it("destroys cleanly without errors even with loaded messages", () => { - setMessages(1, [ - makeMessage({ id: 1 }), - makeMessage({ id: 2 }), - ]); + setMessages(1, [makeMessage({ id: 1 }), makeMessage({ id: 2 })]); msgList.mount(container); expect(container.querySelector(".messages-container")).not.toBeNull(); diff --git a/Client/tauri-client/tests/unit/messages.store.test.ts b/Client/tauri-client/tests/unit/messages.store.test.ts index b560e121..e0441e38 100644 --- a/Client/tauri-client/tests/unit/messages.store.test.ts +++ b/Client/tauri-client/tests/unit/messages.store.test.ts @@ -181,10 +181,7 @@ describe("messages store", () => { describe("setMessages", () => { it("sets messages for a channel", () => { // API returns newest-first; store reverses to oldest-first for display. - const responses = [ - makeMessageResponse({ id: 11 }), - makeMessageResponse({ id: 10 }), - ]; + const responses = [makeMessageResponse({ id: 11 }), makeMessageResponse({ id: 10 })]; setMessages(1, responses, false); const msgs = getChannelMessages(1); @@ -234,11 +231,7 @@ describe("messages store", () => { it("prepends older messages before existing ones", () => { // API returns newest-first; store reverses to oldest-first. setMessages(1, [makeMessageResponse({ id: 20 })], true); - prependMessages( - 1, - [makeMessageResponse({ id: 15 }), makeMessageResponse({ id: 10 })], - false, - ); + prependMessages(1, [makeMessageResponse({ id: 15 }), makeMessageResponse({ id: 10 })], false); const msgs = getChannelMessages(1); expect(msgs).toHaveLength(3); @@ -565,13 +558,16 @@ describe("messages store", () => { it("marks reaction as 'me' when current user reacts", () => { addMessage(makeChatPayload({ id: 100, channel_id: 1 })); - updateReaction({ - message_id: 100, - channel_id: 1, - emoji: "❤️", - user_id: 1, - action: "add", - }, 1); + updateReaction( + { + message_id: 100, + channel_id: 1, + emoji: "❤️", + user_id: 1, + action: "add", + }, + 1, + ); const msg = getChannelMessages(1)[0]!; expect(msg.reactions[0]).toEqual({ emoji: "❤️", count: 1, me: true }); @@ -580,21 +576,27 @@ describe("messages store", () => { it("increments count on existing reaction", () => { addMessage(makeChatPayload({ id: 100, channel_id: 1 })); - updateReaction({ - message_id: 100, - channel_id: 1, - emoji: "👍", - user_id: 2, - action: "add", - }, 1); + updateReaction( + { + message_id: 100, + channel_id: 1, + emoji: "👍", + user_id: 2, + action: "add", + }, + 1, + ); - updateReaction({ - message_id: 100, - channel_id: 1, - emoji: "👍", - user_id: 3, - action: "add", - }, 1); + updateReaction( + { + message_id: 100, + channel_id: 1, + emoji: "👍", + user_id: 3, + action: "add", + }, + 1, + ); const msg = getChannelMessages(1)[0]!; expect(msg.reactions).toHaveLength(1); @@ -604,21 +606,27 @@ describe("messages store", () => { it("sets me=true when incrementing existing reaction by current user", () => { addMessage(makeChatPayload({ id: 100, channel_id: 1 })); - updateReaction({ - message_id: 100, - channel_id: 1, - emoji: "👍", - user_id: 2, - action: "add", - }, 1); + updateReaction( + { + message_id: 100, + channel_id: 1, + emoji: "👍", + user_id: 2, + action: "add", + }, + 1, + ); - updateReaction({ - message_id: 100, - channel_id: 1, - emoji: "👍", - user_id: 1, - action: "add", - }, 1); + updateReaction( + { + message_id: 100, + channel_id: 1, + emoji: "👍", + user_id: 1, + action: "add", + }, + 1, + ); const msg = getChannelMessages(1)[0]!; expect(msg.reactions[0]!.me).toBe(true); @@ -632,7 +640,10 @@ describe("messages store", () => { updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 3, action: "add" }, 1); // Remove one - updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 3, action: "remove" }, 1); + updateReaction( + { message_id: 100, channel_id: 1, emoji: "👍", user_id: 3, action: "remove" }, + 1, + ); const msg = getChannelMessages(1)[0]!; expect(msg.reactions).toHaveLength(1); @@ -643,7 +654,10 @@ describe("messages store", () => { addMessage(makeChatPayload({ id: 100, channel_id: 1 })); updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "add" }, 1); - updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "remove" }, 1); + updateReaction( + { message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "remove" }, + 1, + ); const msg = getChannelMessages(1)[0]!; expect(msg.reactions).toHaveLength(0); @@ -656,7 +670,10 @@ describe("messages store", () => { updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "add" }, 1); expect(getChannelMessages(1)[0]!.reactions[0]!.me).toBe(true); - updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 1, action: "remove" }, 1); + updateReaction( + { message_id: 100, channel_id: 1, emoji: "👍", user_id: 1, action: "remove" }, + 1, + ); const msg = getChannelMessages(1)[0]!; expect(msg.reactions[0]!.me).toBe(false); @@ -665,7 +682,10 @@ describe("messages store", () => { it("is a no-op if the channel does not exist", () => { const before = messagesStore.getState(); - updateReaction({ message_id: 999, channel_id: 99, emoji: "👍", user_id: 1, action: "add" }, 1); + updateReaction( + { message_id: 999, channel_id: 99, emoji: "👍", user_id: 1, action: "add" }, + 1, + ); const after = messagesStore.getState(); expect(before).toBe(after); }); @@ -686,7 +706,10 @@ describe("messages store", () => { updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 1, action: "add" }, 1); updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "add" }, 1); - updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "remove" }, 1); + updateReaction( + { message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "remove" }, + 1, + ); const msg = getChannelMessages(1)[0]!; expect(msg.reactions[0]!.me).toBe(true); @@ -719,7 +742,10 @@ describe("messages store", () => { updateReaction({ message_id: 100, channel_id: 1, emoji: "❤️", user_id: 3, action: "add" }, 1); // Remove 👍 — ❤️ should remain unchanged - updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "remove" }, 1); + updateReaction( + { message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "remove" }, + 1, + ); const msg = getChannelMessages(1)[0]!; expect(msg.reactions).toHaveLength(1); diff --git a/Client/tauri-client/tests/unit/modal-factory.test.ts b/Client/tauri-client/tests/unit/modal-factory.test.ts index 680f23ad..8ea171d4 100644 --- a/Client/tauri-client/tests/unit/modal-factory.test.ts +++ b/Client/tauri-client/tests/unit/modal-factory.test.ts @@ -38,10 +38,7 @@ describe("createModal", () => { it("applies overlay attributes", () => { const content = document.createElement("div"); - const inst = createModal( - { content, overlayAttrs: { "data-testid": "my-modal" } }, - container, - ); + const inst = createModal({ content, overlayAttrs: { "data-testid": "my-modal" } }, container); expect(inst.overlay.getAttribute("data-testid")).toBe("my-modal"); }); @@ -104,10 +101,7 @@ describe("createModal", () => { it("closeOnBackdrop=false prevents backdrop close", () => { const onClose = vi.fn(); const content = document.createElement("div"); - const inst = createModal( - { content, onClose, closeOnBackdrop: false }, - container, - ); + const inst = createModal({ content, onClose, closeOnBackdrop: false }, container); inst.overlay.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -118,10 +112,7 @@ describe("createModal", () => { it("closeOnEscape=false prevents Escape close", () => { const onClose = vi.fn(); const content = document.createElement("div"); - createModal( - { content, onClose, closeOnEscape: false }, - container, - ); + createModal({ content, onClose, closeOnEscape: false }, container); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); @@ -131,10 +122,7 @@ describe("createModal", () => { it("cleans up when external signal is aborted", () => { const externalAc = new AbortController(); const content = document.createElement("div"); - const inst = createModal( - { content, signal: externalAc.signal }, - container, - ); + const inst = createModal({ content, signal: externalAc.signal }, container); expect(container.contains(inst.overlay)).toBe(true); diff --git a/Client/tauri-client/tests/unit/notifications.test.ts b/Client/tauri-client/tests/unit/notifications.test.ts index 62ef7ae0..21e30881 100644 --- a/Client/tauri-client/tests/unit/notifications.test.ts +++ b/Client/tauri-client/tests/unit/notifications.test.ts @@ -84,8 +84,12 @@ const mockGain = { class MockAudioContext { readonly currentTime = 0; readonly destination = {}; - createOscillator() { return mockOscillator; } - createGain() { return mockGain; } + createOscillator() { + return mockOscillator; + } + createGain() { + return mockGain; + } } // Set up AudioContext mock globally @@ -106,7 +110,20 @@ describe("notifyIncomingMessage", () => { // Set up channels store channelsStore.setState(() => ({ - channels: new Map([[1, { id: 1, name: "general", type: "text" as const, category: null, position: 0, unreadCount: 0, lastMessageId: null }]]), + channels: new Map([ + [ + 1, + { + id: 1, + name: "general", + type: "text" as const, + category: null, + position: 0, + unreadCount: 0, + lastMessageId: null, + }, + ], + ]), activeChannelId: 1, roles: [], })); @@ -423,7 +440,9 @@ describe("notifyIncomingMessage", () => { // Remove Notification entirely to trigger the inner catch const originalNotification = globalThis.Notification; Object.defineProperty(globalThis, "Notification", { - get() { throw new Error("Notification not available"); }, + get() { + throw new Error("Notification not available"); + }, configurable: true, }); diff --git a/Client/tauri-client/tests/unit/overlay-managers.test.ts b/Client/tauri-client/tests/unit/overlay-managers.test.ts index 8bfe0f06..af984f7c 100644 --- a/Client/tauri-client/tests/unit/overlay-managers.test.ts +++ b/Client/tauri-client/tests/unit/overlay-managers.test.ts @@ -122,7 +122,9 @@ function makeMockApi(overrides: Record = {}) { ], }), unpinMessage: vi.fn().mockResolvedValue(undefined), - search: vi.fn().mockResolvedValue({ results: [{ channel_id: 1, message_id: 10, content: "hello" }] }), + search: vi + .fn() + .mockResolvedValue({ results: [{ channel_id: 1, message_id: 10, content: "hello" }] }), ...overrides, }; } @@ -155,7 +157,6 @@ describe("createInviteManagerController", () => { const controller = createInviteManagerController({ api: api as never, getRoot: () => root, - }); await controller.open(); @@ -173,7 +174,6 @@ describe("createInviteManagerController", () => { const controller = createInviteManagerController({ api: api as never, getRoot: () => root, - }); await controller.open(); @@ -197,7 +197,6 @@ describe("createInviteManagerController", () => { const controller = createInviteManagerController({ api: api as never, getRoot: () => root, - }); await controller.open(); @@ -219,7 +218,6 @@ describe("createInviteManagerController", () => { const controller = createInviteManagerController({ api: api as never, getRoot: () => root, - }); await controller.open(); @@ -365,10 +363,7 @@ describe("createPinnedPanelController", () => { opts.onJumpToMessage(999); expect(mockScrollToMessage).toHaveBeenCalledWith(999); - expect(mockShowToast).toHaveBeenCalledWith( - expect.stringContaining("not in"), - "info", - ); + expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining("not in"), "info"); // Panel should NOT close when message not found expect(mockPinnedMessagesDestroy).not.toHaveBeenCalled(); }); @@ -963,7 +958,8 @@ describe("createInviteManagerController (additional)", () => { it("onRevokeInvite succeeds when invite code is not found in re-fetch", async () => { const api = makeMockApi({ - getInvites: vi.fn() + getInvites: vi + .fn() .mockResolvedValueOnce([makeInviteResponse()]) // initial load .mockResolvedValueOnce([]), // re-fetch returns empty }); @@ -1076,7 +1072,11 @@ describe("createSearchOverlayController", () => { controller.open(); const opts = (createSearchOverlay as Mock).mock.calls[0]![0] as { - onSearch: (query: string, chId: number | undefined, signal?: AbortSignal) => Promise; + onSearch: ( + query: string, + chId: number | undefined, + signal?: AbortSignal, + ) => Promise; }; const results = await opts.onSearch("hello", 5); @@ -1099,7 +1099,11 @@ describe("createSearchOverlayController", () => { controller.open(); const opts = (createSearchOverlay as Mock).mock.calls[0]![0] as { - onSearch: (query: string, chId: number | undefined, signal?: AbortSignal) => Promise; + onSearch: ( + query: string, + chId: number | undefined, + signal?: AbortSignal, + ) => Promise; }; await expect(opts.onSearch("test", 5)).rejects.toThrow("Aborted"); @@ -1121,7 +1125,11 @@ describe("createSearchOverlayController", () => { controller.open(); const opts = (createSearchOverlay as Mock).mock.calls[0]![0] as { - onSearch: (query: string, chId: number | undefined, signal?: AbortSignal) => Promise; + onSearch: ( + query: string, + chId: number | undefined, + signal?: AbortSignal, + ) => Promise; }; await expect(opts.onSearch("test", 5)).rejects.toThrow("network failure"); @@ -1148,7 +1156,10 @@ describe("createSearchOverlayController", () => { // Mock requestAnimationFrame to execute immediately const origRaf = globalThis.requestAnimationFrame; - globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; }; + globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { + cb(0); + return 0; + }; opts.onSelectResult({ channel_id: 3, message_id: 42 }); @@ -1176,7 +1187,10 @@ describe("createSearchOverlayController", () => { }; const origRaf = globalThis.requestAnimationFrame; - globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; }; + globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { + cb(0); + return 0; + }; opts.onSelectResult({ channel_id: 3, message_id: 999 }); diff --git a/Client/tauri-client/tests/unit/permissions.test.ts b/Client/tauri-client/tests/unit/permissions.test.ts index 98aa5ebf..36640a52 100644 --- a/Client/tauri-client/tests/unit/permissions.test.ts +++ b/Client/tauri-client/tests/unit/permissions.test.ts @@ -1,35 +1,35 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; import { hasPermission, hasAnyPermission, hasAllPermissions, computeEffective, isAdministrator, -} from '../../src/lib/permissions'; -import { Permission } from '../../src/lib/types'; +} from "../../src/lib/permissions"; +import { Permission } from "../../src/lib/types"; // Default role permission values (from SCHEMA.md) -const OWNER_PERMS = 0x7FFFFFFF; -const ADMIN_PERMS = 0x3FFFFFFF; // admin has bits 0-29 but NOT ADMINISTRATOR (bit 30) -const MODERATOR_PERMS = 0x000FFFFF; +const OWNER_PERMS = 0x7fffffff; +const ADMIN_PERMS = 0x3fffffff; // admin has bits 0-29 but NOT ADMINISTRATOR (bit 30) +const MODERATOR_PERMS = 0x000fffff; const MEMBER_PERMS = 0x00000663; -describe('hasPermission', () => { - it('member can SEND_MESSAGES', () => { +describe("hasPermission", () => { + it("member can SEND_MESSAGES", () => { expect(hasPermission(MEMBER_PERMS, Permission.SEND_MESSAGES)).toBe(true); }); - it('member cannot MANAGE_MESSAGES', () => { + it("member cannot MANAGE_MESSAGES", () => { expect(hasPermission(MEMBER_PERMS, Permission.MANAGE_MESSAGES)).toBe(false); }); - it('ADMINISTRATOR bypass — admin with ADMINISTRATOR can do anything', () => { + it("ADMINISTRATOR bypass — admin with ADMINISTRATOR can do anything", () => { const permsWithAdmin = Permission.ADMINISTRATOR; expect(hasPermission(permsWithAdmin, Permission.MANAGE_MESSAGES)).toBe(true); expect(hasPermission(permsWithAdmin, Permission.BAN_MEMBERS)).toBe(true); }); - it('owner has all permissions', () => { + it("owner has all permissions", () => { expect(hasPermission(OWNER_PERMS, Permission.SEND_MESSAGES)).toBe(true); expect(hasPermission(OWNER_PERMS, Permission.MANAGE_SERVER)).toBe(true); expect(hasPermission(OWNER_PERMS, Permission.VIEW_AUDIT_LOG)).toBe(true); @@ -37,28 +37,20 @@ describe('hasPermission', () => { }); }); -describe('hasAnyPermission', () => { - it('returns true if any match', () => { +describe("hasAnyPermission", () => { + it("returns true if any match", () => { expect( - hasAnyPermission( - MEMBER_PERMS, - Permission.SEND_MESSAGES, - Permission.MANAGE_MESSAGES, - ), + hasAnyPermission(MEMBER_PERMS, Permission.SEND_MESSAGES, Permission.MANAGE_MESSAGES), ).toBe(true); }); - it('returns false if none match', () => { - expect( - hasAnyPermission( - MEMBER_PERMS, - Permission.MANAGE_MESSAGES, - Permission.BAN_MEMBERS, - ), - ).toBe(false); + it("returns false if none match", () => { + expect(hasAnyPermission(MEMBER_PERMS, Permission.MANAGE_MESSAGES, Permission.BAN_MEMBERS)).toBe( + false, + ); }); - it('ADMINISTRATOR bypass — always returns true', () => { + it("ADMINISTRATOR bypass — always returns true", () => { expect( hasAnyPermission( Permission.ADMINISTRATOR, @@ -68,7 +60,7 @@ describe('hasAnyPermission', () => { ).toBe(true); }); - it('returns true when only one of many perms matches', () => { + it("returns true when only one of many perms matches", () => { expect( hasAnyPermission( MEMBER_PERMS, @@ -79,35 +71,25 @@ describe('hasAnyPermission', () => { ).toBe(true); }); - it('returns false with zero permissions', () => { - expect( - hasAnyPermission(0, Permission.SEND_MESSAGES, Permission.READ_MESSAGES), - ).toBe(false); + it("returns false with zero permissions", () => { + expect(hasAnyPermission(0, Permission.SEND_MESSAGES, Permission.READ_MESSAGES)).toBe(false); }); }); -describe('hasAllPermissions', () => { - it('returns true when all match', () => { +describe("hasAllPermissions", () => { + it("returns true when all match", () => { expect( - hasAllPermissions( - MEMBER_PERMS, - Permission.SEND_MESSAGES, - Permission.READ_MESSAGES, - ), + hasAllPermissions(MEMBER_PERMS, Permission.SEND_MESSAGES, Permission.READ_MESSAGES), ).toBe(true); }); - it('returns false when one missing', () => { + it("returns false when one missing", () => { expect( - hasAllPermissions( - MEMBER_PERMS, - Permission.SEND_MESSAGES, - Permission.MANAGE_MESSAGES, - ), + hasAllPermissions(MEMBER_PERMS, Permission.SEND_MESSAGES, Permission.MANAGE_MESSAGES), ).toBe(false); }); - it('ADMINISTRATOR bypass — always returns true', () => { + it("ADMINISTRATOR bypass — always returns true", () => { expect( hasAllPermissions( Permission.ADMINISTRATOR, @@ -118,19 +100,17 @@ describe('hasAllPermissions', () => { ).toBe(true); }); - it('returns false when zero perms and checking multiple', () => { - expect( - hasAllPermissions(0, Permission.SEND_MESSAGES, Permission.READ_MESSAGES), - ).toBe(false); + it("returns false when zero perms and checking multiple", () => { + expect(hasAllPermissions(0, Permission.SEND_MESSAGES, Permission.READ_MESSAGES)).toBe(false); }); - it('returns true with no permissions to check (vacuous truth)', () => { + it("returns true with no permissions to check (vacuous truth)", () => { expect(hasAllPermissions(MEMBER_PERMS)).toBe(true); }); }); -describe('computeEffective', () => { - it('allow overrides deny (allow-wins, matches server semantics)', () => { +describe("computeEffective", () => { + it("allow overrides deny (allow-wins, matches server semantics)", () => { const base = MEMBER_PERMS; const allow = Permission.MANAGE_MESSAGES; const deny = Permission.MANAGE_MESSAGES; @@ -138,22 +118,22 @@ describe('computeEffective', () => { expect(effective & Permission.MANAGE_MESSAGES).toBe(Permission.MANAGE_MESSAGES); }); - it('ADMINISTRATOR ignores deny and returns all bits', () => { + it("ADMINISTRATOR ignores deny and returns all bits", () => { // Must use a perm set that actually includes bit 30 (ADMINISTRATOR) const base = OWNER_PERMS; // 0x7FFFFFFF includes ADMINISTRATOR const deny = Permission.SEND_MESSAGES | Permission.MANAGE_SERVER; const effective = computeEffective(base, 0, deny); - expect(effective).toBe(0x7FFFFFFF); + expect(effective).toBe(0x7fffffff); }); - it('non-ADMINISTRATOR admin is affected by deny', () => { + it("non-ADMINISTRATOR admin is affected by deny", () => { // ADMIN_PERMS (0x3FFFFFFF) does NOT have ADMINISTRATOR bit const deny = Permission.SEND_MESSAGES; const effective = computeEffective(ADMIN_PERMS, 0, deny); expect(effective & Permission.SEND_MESSAGES).toBe(0); }); - it('allow adds bits to base', () => { + it("allow adds bits to base", () => { const base = MEMBER_PERMS; const allow = Permission.MANAGE_MESSAGES; const effective = computeEffective(base, allow, 0); @@ -163,43 +143,43 @@ describe('computeEffective', () => { }); }); -describe('isAdministrator', () => { - it('true for owner with ADMINISTRATOR bit', () => { +describe("isAdministrator", () => { + it("true for owner with ADMINISTRATOR bit", () => { expect(isAdministrator(OWNER_PERMS)).toBe(true); }); - it('false for admin without ADMINISTRATOR bit', () => { + it("false for admin without ADMINISTRATOR bit", () => { // ADMIN_PERMS (0x3FFFFFFF) has bits 0-29 but NOT bit 30 expect(isAdministrator(ADMIN_PERMS)).toBe(false); }); - it('false for member', () => { + it("false for member", () => { expect(isAdministrator(MEMBER_PERMS)).toBe(false); }); - it('false for zero permissions', () => { + it("false for zero permissions", () => { expect(isAdministrator(0)).toBe(false); }); - it('true for exactly ADMINISTRATOR bit only', () => { + it("true for exactly ADMINISTRATOR bit only", () => { expect(isAdministrator(Permission.ADMINISTRATOR)).toBe(true); }); }); -describe('edge cases', () => { - it('hasPermission with zero perms returns false', () => { +describe("edge cases", () => { + it("hasPermission with zero perms returns false", () => { expect(hasPermission(0, Permission.SEND_MESSAGES)).toBe(false); }); - it('hasPermission checking ADMINISTRATOR bit directly', () => { + it("hasPermission checking ADMINISTRATOR bit directly", () => { expect(hasPermission(Permission.ADMINISTRATOR, Permission.ADMINISTRATOR)).toBe(true); }); - it('computeEffective with zero base, allow, deny', () => { + it("computeEffective with zero base, allow, deny", () => { expect(computeEffective(0, 0, 0)).toBe(0); }); - it('computeEffective deny removes bits from base', () => { + it("computeEffective deny removes bits from base", () => { const base = Permission.SEND_MESSAGES | Permission.READ_MESSAGES; const deny = Permission.SEND_MESSAGES; const effective = computeEffective(base, 0, deny); @@ -207,7 +187,7 @@ describe('edge cases', () => { expect(effective & Permission.READ_MESSAGES).toBe(Permission.READ_MESSAGES); }); - it('computeEffective with allow and deny for different bits', () => { + it("computeEffective with allow and deny for different bits", () => { const base = Permission.SEND_MESSAGES; const allow = Permission.MANAGE_MESSAGES; const deny = Permission.SEND_MESSAGES; @@ -216,19 +196,19 @@ describe('edge cases', () => { expect(effective & Permission.MANAGE_MESSAGES).toBe(Permission.MANAGE_MESSAGES); }); - it('hasAnyPermission with single matching perm', () => { + it("hasAnyPermission with single matching perm", () => { expect(hasAnyPermission(Permission.SEND_MESSAGES, Permission.SEND_MESSAGES)).toBe(true); }); - it('hasAllPermissions with single matching perm', () => { + it("hasAllPermissions with single matching perm", () => { expect(hasAllPermissions(Permission.SEND_MESSAGES, Permission.SEND_MESSAGES)).toBe(true); }); - it('moderator has KICK_MEMBERS', () => { + it("moderator has KICK_MEMBERS", () => { expect(hasPermission(MODERATOR_PERMS, Permission.KICK_MEMBERS)).toBe(true); }); - it('moderator does not have MANAGE_SERVER', () => { + it("moderator does not have MANAGE_SERVER", () => { expect(hasPermission(MODERATOR_PERMS, Permission.MANAGE_SERVER)).toBe(false); }); }); diff --git a/Client/tauri-client/tests/unit/pinned-messages.test.ts b/Client/tauri-client/tests/unit/pinned-messages.test.ts index c4c2c100..8f628a01 100644 --- a/Client/tauri-client/tests/unit/pinned-messages.test.ts +++ b/Client/tauri-client/tests/unit/pinned-messages.test.ts @@ -3,9 +3,27 @@ import { createPinnedMessages } from "@components/PinnedMessages"; import type { PinnedMessage, PinnedMessagesOptions } from "@components/PinnedMessages"; const samplePins: PinnedMessage[] = [ - { id: 1, content: "Hello world", author: "Alice", timestamp: "2024-01-01T12:00:00Z", avatarColor: "#5865f2" }, - { id: 2, content: "Important notice", author: "Bob", timestamp: "2024-01-02T14:30:00Z", avatarColor: "#e74c3c" }, - { id: 3, content: "Reminder", author: "Charlie", timestamp: "2024-01-03T09:00:00Z", avatarColor: "#2ecc71" }, + { + id: 1, + content: "Hello world", + author: "Alice", + timestamp: "2024-01-01T12:00:00Z", + avatarColor: "#5865f2", + }, + { + id: 2, + content: "Important notice", + author: "Bob", + timestamp: "2024-01-02T14:30:00Z", + avatarColor: "#e74c3c", + }, + { + id: 3, + content: "Reminder", + author: "Charlie", + timestamp: "2024-01-03T09:00:00Z", + avatarColor: "#2ecc71", + }, ]; describe("PinnedMessages", () => { diff --git a/Client/tauri-client/tests/unit/profiles.test.ts b/Client/tauri-client/tests/unit/profiles.test.ts index fc1328d8..c3c577dd 100644 --- a/Client/tauri-client/tests/unit/profiles.test.ts +++ b/Client/tauri-client/tests/unit/profiles.test.ts @@ -34,10 +34,8 @@ function nextUuid(): string { 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[] }> = - []; + let stored: { schemaVersion: number; profiles: readonly ServerProfile[] } | null = null; + const saved: Array<{ schemaVersion: number; profiles: readonly ServerProfile[] }> = []; return { saved, @@ -55,9 +53,7 @@ function createMockBackend(): PersistenceBackend & { // Mock fetch // --------------------------------------------------------------------------- -function createMockFetch( - handler: (url: string, init?: RequestInit) => Promise, -): FetchFn { +function createMockFetch(handler: (url: string, init?: RequestInit) => Promise): FetchFn { return handler as unknown as FetchFn; } @@ -236,9 +232,7 @@ describe("ProfileManager", () => { describe("health checks", () => { it("returns online status for a healthy server", async () => { - const fetchFn = createMockFetch(async () => - jsonResponse({ version: "1.2.3" }), - ); + const fetchFn = createMockFetch(async () => jsonResponse({ version: "1.2.3" })); const m = mgr(fetchFn); const profile = m.addProfile(sampleData); @@ -296,9 +290,7 @@ describe("ProfileManager", () => { }); it("returns offline for non-OK response", async () => { - const fetchFn = createMockFetch(async () => - jsonResponse({ error: "bad" }, 500), - ); + const fetchFn = createMockFetch(async () => jsonResponse({ error: "bad" }, 500)); const m = mgr(fetchFn); const profile = m.addProfile(sampleData); @@ -359,9 +351,7 @@ describe("ProfileManager", () => { 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", - ); + expect(pingedHosts).toContain("https://prod.example.com:443/api/v1/health"); }); it("checkAllHealth returns empty map when no profiles", async () => { @@ -382,10 +372,7 @@ describe("ProfileManager", () => { const exported = m1.exportProfiles(); const backend2 = createMockBackend(); - const m2 = createProfileManager( - backend2, - mockFetch as unknown as FetchFn, - ); + const m2 = createProfileManager(backend2, mockFetch as unknown as FetchFn); const result = m2.importProfiles(exported); expect(result.imported).toBe(2); @@ -446,8 +433,26 @@ describe("ProfileManager", () => { it("rejects import entries with invalid shape", () => { const m = mgr(); const badEntries = [ - { id: "x", name: "", host: "a", username: "b", color: "#000", autoConnect: false, rememberPassword: false, lastConnected: null }, - { id: "y", name: "Valid", host: "valid.com:443", username: "u", color: "#fff", autoConnect: false, rememberPassword: false, lastConnected: null }, + { + id: "x", + name: "", + host: "a", + username: "b", + color: "#000", + autoConnect: false, + rememberPassword: false, + lastConnected: null, + }, + { + id: "y", + name: "Valid", + host: "valid.com:443", + username: "u", + color: "#fff", + autoConnect: false, + rememberPassword: false, + lastConnected: null, + }, ]; const result = m.importProfiles(JSON.stringify(badEntries)); expect(result.imported).toBe(1); @@ -492,10 +497,7 @@ describe("ProfileManager", () => { const exported = m1.exportProfiles(); const backend2 = createMockBackend(); - const m2 = createProfileManager( - backend2, - mockFetch as unknown as FetchFn, - ); + const m2 = createProfileManager(backend2, mockFetch as unknown as FetchFn); m2.importProfiles(exported); const imported = m2.getAll(); @@ -644,9 +646,7 @@ describe("ProfileManager", () => { }); it("returns null onlineUsers when field is missing", async () => { - const fetchFn = createMockFetch(async () => - jsonResponse({ version: "1.2.3" }), - ); + const fetchFn = createMockFetch(async () => jsonResponse({ version: "1.2.3" })); const m = mgr(fetchFn); const profile = m.addProfile(sampleData); @@ -656,9 +656,7 @@ describe("ProfileManager", () => { }); it("returns null version when field is not a string", async () => { - const fetchFn = createMockFetch(async () => - jsonResponse({ version: 123 }), - ); + const fetchFn = createMockFetch(async () => jsonResponse({ version: 123 })); const m = mgr(fetchFn); const profile = m.addProfile(sampleData); @@ -698,9 +696,7 @@ describe("ProfileManager", () => { }); it("healthStatuses updates are visible via store", async () => { - const fetchFn = createMockFetch(async () => - jsonResponse({ version: "3.0.0" }), - ); + const fetchFn = createMockFetch(async () => jsonResponse({ version: "3.0.0" })); const m = mgr(fetchFn); const profile = m.addProfile(sampleData); diff --git a/Client/tauri-client/tests/unit/ptt.test.ts b/Client/tauri-client/tests/unit/ptt.test.ts index bb5de3cd..bf29c825 100644 --- a/Client/tauri-client/tests/unit/ptt.test.ts +++ b/Client/tauri-client/tests/unit/ptt.test.ts @@ -37,7 +37,9 @@ vi.mock("@tauri-apps/api/event", () => ({ vi.mock("@components/settings/helpers", () => ({ loadPref: (key: string, fallback: unknown) => testPrefs.get(key) ?? fallback, - savePref: (key: string, value: unknown) => { testPrefs.set(key, value); }, + savePref: (key: string, value: unknown) => { + testPrefs.set(key, value); + }, })); vi.mock("@stores/voice.store", () => ({ @@ -116,15 +118,15 @@ describe("vkName", () => { }); it("returns 'F12' for 0x7B", () => { - expect(vkName(0x7B)).toBe("F12"); + expect(vkName(0x7b)).toBe("F12"); }); it("returns 'Enter' for 0x0D", () => { - expect(vkName(0x0D)).toBe("Enter"); + expect(vkName(0x0d)).toBe("Enter"); }); it("returns 'Escape' for 0x1B", () => { - expect(vkName(0x1B)).toBe("Escape"); + expect(vkName(0x1b)).toBe("Escape"); }); it("returns 'Backspace' for 0x08", () => { @@ -136,11 +138,11 @@ describe("vkName", () => { }); it("returns 'Delete' for 0x2E", () => { - expect(vkName(0x2E)).toBe("Delete"); + expect(vkName(0x2e)).toBe("Delete"); }); it("returns 'Insert' for 0x2D", () => { - expect(vkName(0x2D)).toBe("Insert"); + expect(vkName(0x2d)).toBe("Insert"); }); it("returns 'Arrow Left' for 0x25", () => { @@ -196,11 +198,11 @@ describe("vkName", () => { }); it("returns 'Z' for 0x5A", () => { - expect(vkName(0x5A)).toBe("Z"); + expect(vkName(0x5a)).toBe("Z"); }); it("returns 'M' for 0x4D", () => { - expect(vkName(0x4D)).toBe("M"); + expect(vkName(0x4d)).toBe("M"); }); }); @@ -221,11 +223,11 @@ describe("vkName", () => { describe("unknown keys — hex fallback", () => { it("returns hex string for an unrecognised VK code", () => { // 0xFF is not in the map and not in any named range - expect(vkName(0xFF)).toBe("Key 0xFF"); + expect(vkName(0xff)).toBe("Key 0xFF"); }); it("returns uppercase hex for 0xAB", () => { - expect(vkName(0xAB)).toBe("Key 0xAB"); + expect(vkName(0xab)).toBe("Key 0xAB"); }); it("returns 'Key 0x0' for vk code 0", () => { diff --git a/Client/tauri-client/tests/unit/quick-switcher.test.ts b/Client/tauri-client/tests/unit/quick-switcher.test.ts index 79bced62..51d19bab 100644 --- a/Client/tauri-client/tests/unit/quick-switcher.test.ts +++ b/Client/tauri-client/tests/unit/quick-switcher.test.ts @@ -196,7 +196,9 @@ describe("QuickSwitcher", () => { it("Ctrl+K closes the switcher when it is open", () => { switcher.mount(container); - document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true })); + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true }), + ); expect(onClose).toHaveBeenCalledOnce(); }); diff --git a/Client/tauri-client/tests/unit/rate-limiter.test.ts b/Client/tauri-client/tests/unit/rate-limiter.test.ts index 9eee1bd2..def79686 100644 --- a/Client/tauri-client/tests/unit/rate-limiter.test.ts +++ b/Client/tauri-client/tests/unit/rate-limiter.test.ts @@ -34,9 +34,7 @@ describe("RateLimiter", () => { }); it("throws when windowMs < 1", () => { - expect(() => new RateLimiter({ maxTokens: 1, windowMs: 0 })).toThrow( - "windowMs must be >= 1", - ); + expect(() => new RateLimiter({ maxTokens: 1, windowMs: 0 })).toThrow("windowMs must be >= 1"); }); // -- tryConsume ----------------------------------------------------------- diff --git a/Client/tauri-client/tests/unit/reaction-controller.test.ts b/Client/tauri-client/tests/unit/reaction-controller.test.ts index 95b31e07..46995bfd 100644 --- a/Client/tauri-client/tests/unit/reaction-controller.test.ts +++ b/Client/tauri-client/tests/unit/reaction-controller.test.ts @@ -4,12 +4,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mocks // --------------------------------------------------------------------------- -const { mockGetChannelMessages, createMockEmojiPickerElement, mockEmojiPickerDestroy } = - vi.hoisted(() => ({ - mockGetChannelMessages: vi.fn((): Array<{ id: number; reactions: Array<{ emoji: string; me: boolean }> }> => []), +const { mockGetChannelMessages, createMockEmojiPickerElement, mockEmojiPickerDestroy } = vi.hoisted( + () => ({ + mockGetChannelMessages: vi.fn( + (): Array<{ id: number; reactions: Array<{ emoji: string; me: boolean }> }> => [], + ), createMockEmojiPickerElement: () => document.createElement("div"), mockEmojiPickerDestroy: vi.fn(), - })); + }), +); vi.mock("@lib/dom", () => ({ createElement: vi.fn((tag: string, attrs?: Record) => { @@ -93,9 +96,7 @@ describe("createReactionController", () => { describe("direct emoji toggle", () => { it("sends reaction_add for a new emoji", () => { - mockGetChannelMessages.mockReturnValue([ - { id: 1, reactions: [] }, - ]); + mockGetChannelMessages.mockReturnValue([{ id: 1, reactions: [] }]); const opts = makeOpts(); const ctrl = createReactionController(opts); @@ -108,9 +109,7 @@ describe("createReactionController", () => { }); it("sends reaction_remove when user already reacted", () => { - mockGetChannelMessages.mockReturnValue([ - { id: 1, reactions: [{ emoji: "👍", me: true }] }, - ]); + mockGetChannelMessages.mockReturnValue([{ id: 1, reactions: [{ emoji: "👍", me: true }] }]); const opts = makeOpts(); const ctrl = createReactionController(opts); @@ -129,9 +128,7 @@ describe("createReactionController", () => { ctrl.handleReaction(1, "👍"); expect(opts.ws.send).not.toHaveBeenCalled(); - expect(opts.showError).toHaveBeenCalledWith( - "Slow down! Please wait before reacting again.", - ); + expect(opts.showError).toHaveBeenCalledWith("Slow down! Please wait before reacting again."); }); }); @@ -149,8 +146,15 @@ describe("createReactionController", () => { const btn = document.createElement("button"); btn.setAttribute("data-testid", "msg-react-1"); btn.getBoundingClientRect = vi.fn(() => ({ - left: 500, right: 530, top: 100, bottom: 130, - width: 30, height: 30, x: 500, y: 100, toJSON: () => {}, + left: 500, + right: 530, + top: 100, + bottom: 130, + width: 30, + height: 30, + x: 500, + y: 100, + toJSON: () => {}, })); document.body.appendChild(btn); @@ -166,8 +170,15 @@ describe("createReactionController", () => { const btn = document.createElement("button"); btn.setAttribute("data-testid", "msg-react-1"); btn.getBoundingClientRect = vi.fn(() => ({ - left: 500, right: 530, top: 100, bottom: 130, - width: 30, height: 30, x: 500, y: 100, toJSON: () => {}, + left: 500, + right: 530, + top: 100, + bottom: 130, + width: 30, + height: 30, + x: 500, + y: 100, + toJSON: () => {}, })); document.body.appendChild(btn); @@ -183,14 +194,19 @@ describe("createReactionController", () => { }); it("sends reaction when emoji is selected from picker", () => { - mockGetChannelMessages.mockReturnValue([ - { id: 1, reactions: [] }, - ]); + mockGetChannelMessages.mockReturnValue([{ id: 1, reactions: [] }]); const btn = document.createElement("button"); btn.setAttribute("data-testid", "msg-react-1"); btn.getBoundingClientRect = vi.fn(() => ({ - left: 500, right: 530, top: 100, bottom: 130, - width: 30, height: 30, x: 500, y: 100, toJSON: () => {}, + left: 500, + right: 530, + top: 100, + bottom: 130, + width: 30, + height: 30, + x: 500, + y: 100, + toJSON: () => {}, })); document.body.appendChild(btn); @@ -211,14 +227,19 @@ describe("createReactionController", () => { }); it("calls pickerDestroy when emoji is selected", () => { - mockGetChannelMessages.mockReturnValue([ - { id: 1, reactions: [] }, - ]); + mockGetChannelMessages.mockReturnValue([{ id: 1, reactions: [] }]); const btn = document.createElement("button"); btn.setAttribute("data-testid", "msg-react-1"); btn.getBoundingClientRect = vi.fn(() => ({ - left: 500, right: 530, top: 100, bottom: 130, - width: 30, height: 30, x: 500, y: 100, toJSON: () => {}, + left: 500, + right: 530, + top: 100, + bottom: 130, + width: 30, + height: 30, + x: 500, + y: 100, + toJSON: () => {}, })); document.body.appendChild(btn); @@ -235,8 +256,15 @@ describe("createReactionController", () => { const btn = document.createElement("button"); btn.setAttribute("data-testid", "msg-react-1"); btn.getBoundingClientRect = vi.fn(() => ({ - left: 500, right: 530, top: 100, bottom: 130, - width: 30, height: 30, x: 500, y: 100, toJSON: () => {}, + left: 500, + right: 530, + top: 100, + bottom: 130, + width: 30, + height: 30, + x: 500, + y: 100, + toJSON: () => {}, })); document.body.appendChild(btn); @@ -254,8 +282,15 @@ describe("createReactionController", () => { const btn = document.createElement("button"); btn.setAttribute("data-testid", "msg-react-1"); btn.getBoundingClientRect = vi.fn(() => ({ - left: 500, right: 530, top: 100, bottom: 130, - width: 30, height: 30, x: 500, y: 100, toJSON: () => {}, + left: 500, + right: 530, + top: 100, + bottom: 130, + width: 30, + height: 30, + x: 500, + y: 100, + toJSON: () => {}, })); document.body.appendChild(btn); @@ -275,8 +310,15 @@ describe("createReactionController", () => { const btn = document.createElement("button"); btn.setAttribute("data-testid", "msg-react-1"); btn.getBoundingClientRect = vi.fn(() => ({ - left: 500, right: 530, top: 100, bottom: 130, - width: 30, height: 30, x: 500, y: 100, toJSON: () => {}, + left: 500, + right: 530, + top: 100, + bottom: 130, + width: 30, + height: 30, + x: 500, + y: 100, + toJSON: () => {}, })); document.body.appendChild(btn); diff --git a/Client/tauri-client/tests/unit/reconcile.test.ts b/Client/tauri-client/tests/unit/reconcile.test.ts index 46b031f7..2075b9ec 100644 --- a/Client/tauri-client/tests/unit/reconcile.test.ts +++ b/Client/tauri-client/tests/unit/reconcile.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from 'vitest'; -import { reconcileList } from '../../src/lib/reconcile'; +import { describe, it, expect, vi } from "vitest"; +import { reconcileList } from "../../src/lib/reconcile"; interface Item { id: string; @@ -7,7 +7,7 @@ interface Item { } function makeContainer(): HTMLDivElement { - return document.createElement('div'); + return document.createElement("div"); } function makeItem(id: string, label: string): Item { @@ -15,9 +15,9 @@ function makeItem(id: string, label: string): Item { } function createEl(item: Item): HTMLDivElement { - const el = document.createElement('div'); + const el = document.createElement("div"); el.textContent = item.label; - el.setAttribute('data-reconcile-key', item.id); + el.setAttribute("data-reconcile-key", item.id); return el; } @@ -26,15 +26,13 @@ function updateEl(el: Element, item: Item): void { } function getKeys(container: Element): string[] { - return Array.from(container.children).map( - (c) => c.getAttribute('data-reconcile-key') ?? '', - ); + return Array.from(container.children).map((c) => c.getAttribute("data-reconcile-key") ?? ""); } -describe('reconcileList', () => { - it('inserts new items into empty container', () => { +describe("reconcileList", () => { + it("inserts new items into empty container", () => { const container = makeContainer(); - const items = [makeItem('a', 'A'), makeItem('b', 'B')]; + const items = [makeItem("a", "A"), makeItem("b", "B")]; reconcileList({ container, @@ -45,14 +43,14 @@ describe('reconcileList', () => { }); expect(container.children.length).toBe(2); - expect(getKeys(container)).toEqual(['a', 'b']); - expect(container.children[0]!.textContent).toBe('A'); - expect(container.children[1]!.textContent).toBe('B'); + expect(getKeys(container)).toEqual(["a", "b"]); + expect(container.children[0]!.textContent).toBe("A"); + expect(container.children[1]!.textContent).toBe("B"); }); - it('removes deleted items', () => { + it("removes deleted items", () => { const container = makeContainer(); - const items = [makeItem('a', 'A'), makeItem('b', 'B'), makeItem('c', 'C')]; + const items = [makeItem("a", "A"), makeItem("b", "B"), makeItem("c", "C")]; reconcileList({ container, @@ -66,19 +64,19 @@ describe('reconcileList', () => { // Remove 'b' reconcileList({ container, - items: [makeItem('a', 'A'), makeItem('c', 'C')], + items: [makeItem("a", "A"), makeItem("c", "C")], key: (i) => i.id, create: createEl, update: updateEl, }); expect(container.children.length).toBe(2); - expect(getKeys(container)).toEqual(['a', 'c']); + expect(getKeys(container)).toEqual(["a", "c"]); }); - it('reorders moved items', () => { + it("reorders moved items", () => { const container = makeContainer(); - const items = [makeItem('a', 'A'), makeItem('b', 'B'), makeItem('c', 'C')]; + const items = [makeItem("a", "A"), makeItem("b", "B"), makeItem("c", "C")]; reconcileList({ container, @@ -91,18 +89,18 @@ describe('reconcileList', () => { // Reverse order reconcileList({ container, - items: [makeItem('c', 'C'), makeItem('b', 'B'), makeItem('a', 'A')], + items: [makeItem("c", "C"), makeItem("b", "B"), makeItem("a", "A")], key: (i) => i.id, create: createEl, update: updateEl, }); - expect(getKeys(container)).toEqual(['c', 'b', 'a']); + expect(getKeys(container)).toEqual(["c", "b", "a"]); }); - it('updates changed items in-place (preserves DOM reference)', () => { + it("updates changed items in-place (preserves DOM reference)", () => { const container = makeContainer(); - const items = [makeItem('a', 'A'), makeItem('b', 'B')]; + const items = [makeItem("a", "A"), makeItem("b", "B")]; reconcileList({ container, @@ -118,7 +116,7 @@ describe('reconcileList', () => { // Update label for 'a' reconcileList({ container, - items: [makeItem('a', 'A-updated'), makeItem('b', 'B')], + items: [makeItem("a", "A-updated"), makeItem("b", "B")], key: (i) => i.id, create: createEl, update: updateEl, @@ -127,10 +125,10 @@ describe('reconcileList', () => { // SAME DOM elements — not rebuilt expect(container.children[0]).toBe(origA); expect(container.children[1]).toBe(origB); - expect(origA.textContent).toBe('A-updated'); + expect(origA.textContent).toBe("A-updated"); }); - it('handles empty → items', () => { + it("handles empty → items", () => { const container = makeContainer(); reconcileList({ @@ -144,21 +142,21 @@ describe('reconcileList', () => { reconcileList({ container, - items: [makeItem('x', 'X')], + items: [makeItem("x", "X")], key: (i) => i.id, create: createEl, update: updateEl, }); expect(container.children.length).toBe(1); - expect(getKeys(container)).toEqual(['x']); + expect(getKeys(container)).toEqual(["x"]); }); - it('handles items → empty', () => { + it("handles items → empty", () => { const container = makeContainer(); reconcileList({ container, - items: [makeItem('a', 'A'), makeItem('b', 'B')], + items: [makeItem("a", "A"), makeItem("b", "B")], key: (i) => i.id, create: createEl, update: updateEl, @@ -175,9 +173,9 @@ describe('reconcileList', () => { expect(container.children.length).toBe(0); }); - it('no-op when identical items', () => { + it("no-op when identical items", () => { const container = makeContainer(); - const items = [makeItem('a', 'A'), makeItem('b', 'B')]; + const items = [makeItem("a", "A"), makeItem("b", "B")]; const createSpy = vi.fn(createEl); reconcileList({ @@ -195,7 +193,7 @@ describe('reconcileList', () => { // Same items again reconcileList({ container, - items: [makeItem('a', 'A'), makeItem('b', 'B')], + items: [makeItem("a", "A"), makeItem("b", "B")], key: (i) => i.id, create: createSpy, update: updateEl, @@ -208,12 +206,12 @@ describe('reconcileList', () => { expect(container.children[1]).toBe(origB); }); - it('handles simultaneous add, remove, and reorder', () => { + it("handles simultaneous add, remove, and reorder", () => { const container = makeContainer(); reconcileList({ container, - items: [makeItem('a', 'A'), makeItem('b', 'B'), makeItem('c', 'C')], + items: [makeItem("a", "A"), makeItem("b", "B"), makeItem("c", "C")], key: (i) => i.id, create: createEl, update: updateEl, @@ -224,13 +222,13 @@ describe('reconcileList', () => { // Remove 'a', add 'd', reorder: c, d, b reconcileList({ container, - items: [makeItem('c', 'C'), makeItem('d', 'D'), makeItem('b', 'B')], + items: [makeItem("c", "C"), makeItem("d", "D"), makeItem("b", "B")], key: (i) => i.id, create: createEl, update: updateEl, }); - expect(getKeys(container)).toEqual(['c', 'd', 'b']); + expect(getKeys(container)).toEqual(["c", "d", "b"]); expect(container.children.length).toBe(3); // 'c' element preserved expect(container.children[0]).toBe(origC); diff --git a/Client/tauri-client/tests/unit/renderers.test.ts b/Client/tauri-client/tests/unit/renderers.test.ts index f9f41315..a8893361 100644 --- a/Client/tauri-client/tests/unit/renderers.test.ts +++ b/Client/tauri-client/tests/unit/renderers.test.ts @@ -272,11 +272,21 @@ describe("renderers", () => { const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); container.appendChild(el); - expect(container.querySelector("[data-testid='msg-react-1']")?.getAttribute("aria-label")).toBe("React"); - expect(container.querySelector("[data-testid='msg-reply-1']")?.getAttribute("aria-label")).toBe("Reply"); - expect(container.querySelector("[data-testid='msg-pin-1']")?.getAttribute("aria-label")).toBe("Pin"); - expect(container.querySelector("[data-testid='msg-edit-1']")?.getAttribute("aria-label")).toBe("Edit"); - expect(container.querySelector("[data-testid='msg-delete-1']")?.getAttribute("aria-label")).toBe("Delete"); + expect( + container.querySelector("[data-testid='msg-react-1']")?.getAttribute("aria-label"), + ).toBe("React"); + expect( + container.querySelector("[data-testid='msg-reply-1']")?.getAttribute("aria-label"), + ).toBe("Reply"); + expect(container.querySelector("[data-testid='msg-pin-1']")?.getAttribute("aria-label")).toBe( + "Pin", + ); + expect( + container.querySelector("[data-testid='msg-edit-1']")?.getAttribute("aria-label"), + ).toBe("Edit"); + expect( + container.querySelector("[data-testid='msg-delete-1']")?.getAttribute("aria-label"), + ).toBe("Delete"); ac.abort(); }); @@ -287,7 +297,9 @@ describe("renderers", () => { const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); container.appendChild(el); - expect(container.querySelector("[data-testid='msg-pin-1']")?.getAttribute("aria-label")).toBe("Unpin"); + expect(container.querySelector("[data-testid='msg-pin-1']")?.getAttribute("aria-label")).toBe( + "Unpin", + ); ac.abort(); }); @@ -324,7 +336,13 @@ describe("renderers", () => { it("renders attachments for image types", () => { const msg = makeMessage({ attachments: [ - { id: "1", filename: "photo.png", size: 1024, mime: "image/png", url: "/uploads/photo.png" }, + { + id: "1", + filename: "photo.png", + size: 1024, + mime: "image/png", + url: "/uploads/photo.png", + }, ], }); const ac = new AbortController(); @@ -339,7 +357,13 @@ describe("renderers", () => { it("renders attachments for file types", () => { const msg = makeMessage({ attachments: [ - { id: "1", filename: "doc.pdf", size: 2048, mime: "application/pdf", url: "/uploads/doc.pdf" }, + { + id: "1", + filename: "doc.pdf", + size: 2048, + mime: "application/pdf", + url: "/uploads/doc.pdf", + }, ], }); const ac = new AbortController(); @@ -401,11 +425,7 @@ describe("renderers", () => { }); it("returns a valid HH:MM string for every supported timestamp format", () => { - const formats = [ - "2026-03-19T08:30:00Z", - "2026-03-19 08:30:00", - "2026-03-19T08:30:00+00:00", - ]; + const formats = ["2026-03-19T08:30:00Z", "2026-03-19 08:30:00", "2026-03-19T08:30:00+00:00"]; for (const ts of formats) { expect(formatTime(ts)).toMatch(/^\d{2}:\d{2}$/); } @@ -695,9 +715,11 @@ describe("renderers", () => { // loadPref reads from localStorage with the "owncord:settings:" prefix localStorage.setItem("owncord:settings:developerMode", "true"); // Dispatch pref-change to invalidate the cached developerModeEnabled value - window.dispatchEvent(new CustomEvent("owncord:pref-change", { - detail: { key: "developerMode" }, - })); + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { + detail: { key: "developerMode" }, + }), + ); const msg = makeMessage(); const ac = new AbortController(); @@ -710,17 +732,21 @@ describe("renderers", () => { // Clean up: restore developer mode to false localStorage.setItem("owncord:settings:developerMode", "false"); - window.dispatchEvent(new CustomEvent("owncord:pref-change", { - detail: { key: "developerMode" }, - })); + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { + detail: { key: "developerMode" }, + }), + ); ac.abort(); }); it("Copy ID button calls clipboard.writeText on click", () => { localStorage.setItem("owncord:settings:developerMode", "true"); - window.dispatchEvent(new CustomEvent("owncord:pref-change", { - detail: { key: "developerMode" }, - })); + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { + detail: { key: "developerMode" }, + }), + ); const writeTextMock = vi.fn().mockResolvedValue(undefined); Object.assign(navigator, { clipboard: { writeText: writeTextMock } }); @@ -738,17 +764,21 @@ describe("renderers", () => { // Clean up localStorage.setItem("owncord:settings:developerMode", "false"); - window.dispatchEvent(new CustomEvent("owncord:pref-change", { - detail: { key: "developerMode" }, - })); + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { + detail: { key: "developerMode" }, + }), + ); ac.abort(); }); it("Copy ID button handles clipboard failure gracefully", () => { localStorage.setItem("owncord:settings:developerMode", "true"); - window.dispatchEvent(new CustomEvent("owncord:pref-change", { - detail: { key: "developerMode" }, - })); + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { + detail: { key: "developerMode" }, + }), + ); Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("clipboard unavailable")) }, @@ -765,17 +795,21 @@ describe("renderers", () => { // Clean up localStorage.setItem("owncord:settings:developerMode", "false"); - window.dispatchEvent(new CustomEvent("owncord:pref-change", { - detail: { key: "developerMode" }, - })); + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { + detail: { key: "developerMode" }, + }), + ); ac.abort(); }); it("does not render Copy ID button when developerMode is disabled", () => { localStorage.setItem("owncord:settings:developerMode", "false"); - window.dispatchEvent(new CustomEvent("owncord:pref-change", { - detail: { key: "developerMode" }, - })); + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { + detail: { key: "developerMode" }, + }), + ); const msg = makeMessage(); const ac = new AbortController(); @@ -988,7 +1022,9 @@ describe("renderers", () => { it("returns role for known member", () => { membersStore.setState((prev) => ({ ...prev, - members: new Map([[42, { id: 42, username: "admin", avatar: null, role: "admin", status: "online" }]]), + members: new Map([ + [42, { id: 42, username: "admin", avatar: null, role: "admin", status: "online" }], + ]), })); expect(getUserRole(42)).toBe("admin"); }); @@ -998,9 +1034,11 @@ describe("renderers", () => { afterEach(() => { // Restore roleColors to default (true) localStorage.setItem("owncord:settings:roleColors", "true"); - window.dispatchEvent(new CustomEvent("owncord:pref-change", { - detail: { key: "roleColors" }, - })); + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { + detail: { key: "roleColors" }, + }), + ); }); it("returns owner color for 'owner' role", () => { @@ -1025,9 +1063,11 @@ describe("renderers", () => { it("returns member color for all roles when roleColors is disabled", () => { localStorage.setItem("owncord:settings:roleColors", "false"); - window.dispatchEvent(new CustomEvent("owncord:pref-change", { - detail: { key: "roleColors" }, - })); + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { + detail: { key: "roleColors" }, + }), + ); expect(roleColorVar("owner")).toBe("var(--role-member)"); expect(roleColorVar("admin")).toBe("var(--role-member)"); @@ -1037,15 +1077,19 @@ describe("renderers", () => { it("re-enables role colors when pref changes back to true", () => { localStorage.setItem("owncord:settings:roleColors", "false"); - window.dispatchEvent(new CustomEvent("owncord:pref-change", { - detail: { key: "roleColors" }, - })); + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { + detail: { key: "roleColors" }, + }), + ); expect(roleColorVar("owner")).toBe("var(--role-member)"); localStorage.setItem("owncord:settings:roleColors", "true"); - window.dispatchEvent(new CustomEvent("owncord:pref-change", { - detail: { key: "roleColors" }, - })); + window.dispatchEvent( + new CustomEvent("owncord:pref-change", { + detail: { key: "roleColors" }, + }), + ); expect(roleColorVar("owner")).toBe("var(--role-owner)"); }); }); @@ -1201,7 +1245,6 @@ describe("renderers", () => { expect(container.textContent).toContain("before"); expect(container.textContent).toContain("after"); }); - }); // --------------------------------------------------------------------------- diff --git a/Client/tauri-client/tests/unit/rnnoise-worklet.test.ts b/Client/tauri-client/tests/unit/rnnoise-worklet.test.ts index 652802a9..b62d3998 100644 --- a/Client/tauri-client/tests/unit/rnnoise-worklet.test.ts +++ b/Client/tauri-client/tests/unit/rnnoise-worklet.test.ts @@ -4,20 +4,22 @@ const REGISTERED_NAME = "rnnoise-processor"; describe("rnnoise-worklet", () => { let registerProcessorMock: ReturnType; - let processorCtor: (new () => { - _processFrame(): void; - _outAvailable: number; - _outReadPos: number; - _outWritePos: number; - _outSampleOffset: number; - _inputPtr: number; - _outputPtr: number; - _state: number; - _inputRing: Float32Array; - _outBuffer: Float32Array; - _heapF32: Float32Array | null; - _instance: { exports: { rnnoise_process_frame: ReturnType } } | null; - }) | null; + let processorCtor: + | (new () => { + _processFrame(): void; + _outAvailable: number; + _outReadPos: number; + _outWritePos: number; + _outSampleOffset: number; + _inputPtr: number; + _outputPtr: number; + _state: number; + _inputRing: Float32Array; + _outBuffer: Float32Array; + _heapF32: Float32Array | null; + _instance: { exports: { rnnoise_process_frame: ReturnType } } | null; + }) + | null; beforeEach(() => { vi.resetModules(); @@ -78,4 +80,4 @@ describe("rnnoise-worklet", () => { expect(processor._outReadPos).toBe(4); expect(processor._outSampleOffset).toBe(0); }); -}); \ No newline at end of file +}); diff --git a/Client/tauri-client/tests/unit/search-overlay.test.ts b/Client/tauri-client/tests/unit/search-overlay.test.ts index e6839290..c0c80b69 100644 --- a/Client/tauri-client/tests/unit/search-overlay.test.ts +++ b/Client/tauri-client/tests/unit/search-overlay.test.ts @@ -220,10 +220,7 @@ describe("createSearchOverlay", () => { describe("keyboard navigation", () => { it("ArrowDown moves active index", async () => { - const results = [ - makeResult({ message_id: 1 }), - makeResult({ message_id: 2 }), - ]; + const results = [makeResult({ message_id: 1 }), makeResult({ message_id: 2 })]; const onSearch = vi.fn().mockResolvedValue(results); const opts = makeOptions({ onSearch }); const overlay = createSearchOverlay(opts); @@ -235,12 +232,20 @@ describe("createSearchOverlay", () => { await vi.advanceTimersByTimeAsync(300); // First item should be active - expect(container.querySelector("[data-testid='search-result-0']")!.classList.contains("search-result-item--active")).toBe(true); + expect( + container + .querySelector("[data-testid='search-result-0']")! + .classList.contains("search-result-item--active"), + ).toBe(true); // Arrow down input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); - expect(container.querySelector("[data-testid='search-result-1']")!.classList.contains("search-result-item--active")).toBe(true); + expect( + container + .querySelector("[data-testid='search-result-1']")! + .classList.contains("search-result-item--active"), + ).toBe(true); overlay.destroy?.(); }); @@ -354,20 +359,25 @@ describe("createSearchOverlay", () => { await vi.advanceTimersByTimeAsync(300); // First item is active - expect(container.querySelector("[data-testid='search-result-0']")!.classList.contains("search-result-item--active")).toBe(true); + expect( + container + .querySelector("[data-testid='search-result-0']")! + .classList.contains("search-result-item--active"), + ).toBe(true); // Arrow up should wrap to last input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); - expect(container.querySelector("[data-testid='search-result-2']")!.classList.contains("search-result-item--active")).toBe(true); + expect( + container + .querySelector("[data-testid='search-result-2']")! + .classList.contains("search-result-item--active"), + ).toBe(true); overlay.destroy?.(); }); it("ArrowDown wraps from last to first", async () => { - const results = [ - makeResult({ message_id: 1 }), - makeResult({ message_id: 2 }), - ]; + const results = [makeResult({ message_id: 1 }), makeResult({ message_id: 2 })]; const onSearch = vi.fn().mockResolvedValue(results); const opts = makeOptions({ onSearch }); const overlay = createSearchOverlay(opts); @@ -380,11 +390,19 @@ describe("createSearchOverlay", () => { // Move to last input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); - expect(container.querySelector("[data-testid='search-result-1']")!.classList.contains("search-result-item--active")).toBe(true); + expect( + container + .querySelector("[data-testid='search-result-1']")! + .classList.contains("search-result-item--active"), + ).toBe(true); // One more should wrap to first input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); - expect(container.querySelector("[data-testid='search-result-0']")!.classList.contains("search-result-item--active")).toBe(true); + expect( + container + .querySelector("[data-testid='search-result-0']")! + .classList.contains("search-result-item--active"), + ).toBe(true); overlay.destroy?.(); }); @@ -393,10 +411,12 @@ describe("createSearchOverlay", () => { it("aborts previous search when a new search starts", async () => { let abortedSignal: AbortSignal | undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const onSearch = vi.fn().mockImplementation((_q: string, _ch: number | undefined, signal: AbortSignal) => { - abortedSignal = signal; - return new Promise(() => {}); // Never resolves — stalled search - }); + const onSearch = vi + .fn() + .mockImplementation((_q: string, _ch: number | undefined, signal: AbortSignal) => { + abortedSignal = signal; + return new Promise(() => {}); // Never resolves — stalled search + }); const opts = makeOptions({ onSearch }); const overlay = createSearchOverlay(opts); overlay.mount(container); @@ -411,7 +431,9 @@ describe("createSearchOverlay", () => { const firstSignal = abortedSignal; // Second search — should abort the first + // Advance past the MIN_SEARCH_INTERVAL_MS (500ms) rate limit before triggering debounce onSearch.mockImplementation(() => Promise.resolve([])); + await vi.advanceTimersByTimeAsync(200); // now 500ms since first search fired input.value = "second"; input.dispatchEvent(new Event("input")); await vi.advanceTimersByTimeAsync(300); @@ -425,8 +447,11 @@ describe("createSearchOverlay", () => { it("shows 'Searching...' status during search", async () => { let resolveSearch: ((results: SearchResultItem[]) => void) | undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const onSearch = vi.fn().mockImplementation(() => - new Promise((resolve) => { resolveSearch = resolve; }), + const onSearch = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveSearch = resolve; + }), ); const opts = makeOptions({ onSearch }); const overlay = createSearchOverlay(opts); diff --git a/Client/tauri-client/tests/unit/server-panel.test.ts b/Client/tauri-client/tests/unit/server-panel.test.ts index dd337a91..f619dc32 100644 --- a/Client/tauri-client/tests/unit/server-panel.test.ts +++ b/Client/tauri-client/tests/unit/server-panel.test.ts @@ -193,10 +193,7 @@ describe("ServerPanel", () => { describe("server click", () => { it("calls onServerClick with host when a server item is clicked", () => { const onServerClick = vi.fn(); - const panel = createServerPanel( - makeOpts({ onServerClick }), - [SIMPLE_PROFILES[0]!], - ); + const panel = createServerPanel(makeOpts({ onServerClick }), [SIMPLE_PROFILES[0]!]); container.appendChild(panel.element); const item = container.querySelector(".server-item") as HTMLElement; @@ -208,10 +205,7 @@ describe("ServerPanel", () => { it("calls onServerClick with host AND username for full profiles", () => { const onServerClick = vi.fn(); const fp = fullProfile(); - const panel = createServerPanel( - makeOpts({ onServerClick }), - [fp], - ); + const panel = createServerPanel(makeOpts({ onServerClick }), [fp]); container.appendChild(panel.element); const item = container.querySelector(".server-item") as HTMLElement; @@ -240,21 +234,15 @@ describe("ServerPanel", () => { token: "tok", }); - const panel = createServerPanel( - makeOpts({ onCredentialLoaded }), - [SIMPLE_PROFILES[0]!], - ); + const panel = createServerPanel(makeOpts({ onCredentialLoaded }), [SIMPLE_PROFILES[0]!]); container.appendChild(panel.element); const item = container.querySelector(".server-item") as HTMLElement; item.click(); + // Password is no longer returned from credential store over IPC (security hardening) await vi.waitFor(() => { - expect(onCredentialLoaded).toHaveBeenCalledWith( - "localhost:8443", - "saveduser", - "savedpass", - ); + expect(onCredentialLoaded).toHaveBeenCalledWith("localhost:8443", "saveduser", undefined); }); }); @@ -262,10 +250,7 @@ describe("ServerPanel", () => { const onCredentialLoaded = vi.fn(); vi.mocked(loadCredential).mockResolvedValueOnce(null); - const panel = createServerPanel( - makeOpts({ onCredentialLoaded }), - [SIMPLE_PROFILES[0]!], - ); + const panel = createServerPanel(makeOpts({ onCredentialLoaded }), [SIMPLE_PROFILES[0]!]); container.appendChild(panel.element); const item = container.querySelector(".server-item") as HTMLElement; @@ -338,10 +323,7 @@ describe("ServerPanel", () => { it("calls onToggleAutoLogin with profile id and toggled state", () => { const onToggleAutoLogin = vi.fn(); const fp = fullProfile({ autoConnect: false }); - const panel = createServerPanel( - makeOpts({ onToggleAutoLogin }), - [fp], - ); + const panel = createServerPanel(makeOpts({ onToggleAutoLogin }), [fp]); container.appendChild(panel.element); const autoLoginBtn = container.querySelector(".auto-login") as HTMLElement; @@ -354,10 +336,7 @@ describe("ServerPanel", () => { const onServerClick = vi.fn(); const onToggleAutoLogin = vi.fn(); const fp = fullProfile(); - const panel = createServerPanel( - makeOpts({ onServerClick, onToggleAutoLogin }), - [fp], - ); + const panel = createServerPanel(makeOpts({ onServerClick, onToggleAutoLogin }), [fp]); container.appendChild(panel.element); const autoLoginBtn = container.querySelector(".auto-login") as HTMLElement; @@ -416,10 +395,7 @@ describe("ServerPanel", () => { it("calls onDeleteProfile with profile id on click", () => { const onDeleteProfile = vi.fn(); const fp = fullProfile(); - const panel = createServerPanel( - makeOpts({ onDeleteProfile }), - [fp], - ); + const panel = createServerPanel(makeOpts({ onDeleteProfile }), [fp]); container.appendChild(panel.element); const deleteBtn = container.querySelector(".srv-btn.danger") as HTMLElement; @@ -432,10 +408,7 @@ describe("ServerPanel", () => { const onServerClick = vi.fn(); const onDeleteProfile = vi.fn(); const fp = fullProfile(); - const panel = createServerPanel( - makeOpts({ onServerClick, onDeleteProfile }), - [fp], - ); + const panel = createServerPanel(makeOpts({ onServerClick, onDeleteProfile }), [fp]); container.appendChild(panel.element); const deleteBtn = container.querySelector(".srv-btn.danger") as HTMLElement; diff --git a/Client/tauri-client/tests/unit/settings-overlay.test.ts b/Client/tauri-client/tests/unit/settings-overlay.test.ts index 0d7706cf..d6d3f4ad 100644 --- a/Client/tauri-client/tests/unit/settings-overlay.test.ts +++ b/Client/tauri-client/tests/unit/settings-overlay.test.ts @@ -108,7 +108,9 @@ describe("SettingsOverlay", () => { const overlay = createSettingsOverlay(defaultOptions); overlay.mount(container); - const activeTab = container.querySelector(".settings-sidebar > button.settings-nav-item.active"); + const activeTab = container.querySelector( + ".settings-sidebar > button.settings-nav-item.active", + ); expect(activeTab?.textContent).toBe("Account"); overlay.destroy?.(); @@ -344,8 +346,9 @@ describe("SettingsOverlay", () => { (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; + const changePwBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Change Password", + ) as HTMLElement; changePwBtn.click(); expect(defaultOptions.onChangePassword).not.toHaveBeenCalled(); @@ -362,8 +365,9 @@ describe("SettingsOverlay", () => { (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; + const changePwBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Change Password", + ) as HTMLElement; changePwBtn.click(); expect(defaultOptions.onChangePassword).not.toHaveBeenCalled(); @@ -381,8 +385,9 @@ describe("SettingsOverlay", () => { (inputs[1] as HTMLInputElement).value = "newpassword123"; (inputs[2] as HTMLInputElement).value = "newpassword123"; - const changePwBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Change Password") as HTMLElement; + const changePwBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Change Password", + ) as HTMLElement; changePwBtn.click(); await vi.waitFor(() => { @@ -409,14 +414,17 @@ describe("SettingsOverlay", () => { (inputs[1] as HTMLInputElement).value = "newpassword123"; (inputs[2] as HTMLInputElement).value = "newpassword123"; - const changePwBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Change Password") as HTMLElement; + const changePwBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Change Password", + ) as HTMLElement; changePwBtn.click(); await vi.waitFor(() => { // Find the error element near the password fields const errorEls = container.querySelectorAll("div[style*='color:var(--red)']"); - const pwError = Array.from(errorEls).find((el) => el.textContent === "Incorrect old password"); + const pwError = Array.from(errorEls).find( + (el) => el.textContent === "Incorrect old password", + ); expect(pwError).not.toBeUndefined(); }); @@ -434,8 +442,9 @@ describe("SettingsOverlay", () => { const editInput = container.querySelector("input.form-input[type='text']") as HTMLInputElement; editInput.value = "taken-name"; - const saveBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Save") as HTMLElement; + const saveBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Save", + ) as HTMLElement; saveBtn.click(); await vi.waitFor(() => { @@ -452,8 +461,9 @@ describe("SettingsOverlay", () => { overlay.mount(container); // Click "Edit User Profile" button instead of "Edit" button - const editProfileBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Edit User Profile") as HTMLElement; + const editProfileBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Edit User Profile", + ) as HTMLElement; editProfileBtn.click(); const editInput = container.querySelector("input.form-input[type='text']") as HTMLInputElement; @@ -474,8 +484,9 @@ describe("SettingsOverlay", () => { editBtn.click(); // Click cancel - const cancelBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Cancel") as HTMLElement; + const cancelBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Cancel", + ) as HTMLElement; cancelBtn.click(); // Edit form should be hidden @@ -492,10 +503,14 @@ describe("SettingsOverlay", () => { const overlay = createSettingsOverlay(defaultOptions); overlay.mount(container); - const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement; + const triggerBtn = container.querySelector( + "[data-testid='delete-account-trigger']", + ) as HTMLElement; expect(triggerBtn).not.toBeNull(); - const confirmArea = container.querySelector("[data-testid='delete-account-confirm-area']") as HTMLElement; + const confirmArea = container.querySelector( + "[data-testid='delete-account-confirm-area']", + ) as HTMLElement; expect(confirmArea.style.display).toBe("none"); triggerBtn.click(); @@ -510,10 +525,14 @@ describe("SettingsOverlay", () => { const overlay = createSettingsOverlay(defaultOptions); overlay.mount(container); - const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement; + const triggerBtn = container.querySelector( + "[data-testid='delete-account-trigger']", + ) as HTMLElement; triggerBtn.click(); - const confirmArea = container.querySelector("[data-testid='delete-account-confirm-area']") as HTMLElement; + const confirmArea = container.querySelector( + "[data-testid='delete-account-confirm-area']", + ) as HTMLElement; expect(confirmArea.style.display).toBe("block"); const cancelBtn = confirmArea.querySelector("button:not(.account-delete-btn)") as HTMLElement; @@ -529,10 +548,14 @@ describe("SettingsOverlay", () => { const overlay = createSettingsOverlay(defaultOptions); overlay.mount(container); - const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement; + const triggerBtn = container.querySelector( + "[data-testid='delete-account-trigger']", + ) as HTMLElement; triggerBtn.click(); - const confirmBtn = container.querySelector("[data-testid='delete-account-confirm']") as HTMLElement; + const confirmBtn = container.querySelector( + "[data-testid='delete-account-confirm']", + ) as HTMLElement; confirmBtn.click(); const errorEl = container.querySelector("[data-testid='delete-account-error']") as HTMLElement; @@ -546,13 +569,19 @@ describe("SettingsOverlay", () => { const overlay = createSettingsOverlay(defaultOptions); overlay.mount(container); - const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement; + const triggerBtn = container.querySelector( + "[data-testid='delete-account-trigger']", + ) as HTMLElement; triggerBtn.click(); - const passwordInput = container.querySelector("[data-testid='delete-account-password']") as HTMLInputElement; + const passwordInput = container.querySelector( + "[data-testid='delete-account-password']", + ) as HTMLInputElement; passwordInput.value = "mypassword123"; - const confirmBtn = container.querySelector("[data-testid='delete-account-confirm']") as HTMLButtonElement; + const confirmBtn = container.querySelector( + "[data-testid='delete-account-confirm']", + ) as HTMLButtonElement; confirmBtn.click(); expect(defaultOptions.onDeleteAccount).toHaveBeenCalledWith("mypassword123"); @@ -564,13 +593,19 @@ describe("SettingsOverlay", () => { const overlay = createSettingsOverlay(defaultOptions); overlay.mount(container); - const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement; + const triggerBtn = container.querySelector( + "[data-testid='delete-account-trigger']", + ) as HTMLElement; triggerBtn.click(); - const passwordInput = container.querySelector("[data-testid='delete-account-password']") as HTMLInputElement; + const passwordInput = container.querySelector( + "[data-testid='delete-account-password']", + ) as HTMLInputElement; passwordInput.value = "mypassword123"; - const confirmBtn = container.querySelector("[data-testid='delete-account-confirm']") as HTMLButtonElement; + const confirmBtn = container.querySelector( + "[data-testid='delete-account-confirm']", + ) as HTMLButtonElement; confirmBtn.click(); expect(confirmBtn.disabled).toBe(true); @@ -588,13 +623,19 @@ describe("SettingsOverlay", () => { const overlay = createSettingsOverlay(failOptions); overlay.mount(container); - const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement; + const triggerBtn = container.querySelector( + "[data-testid='delete-account-trigger']", + ) as HTMLElement; triggerBtn.click(); - const passwordInput = container.querySelector("[data-testid='delete-account-password']") as HTMLInputElement; + const passwordInput = container.querySelector( + "[data-testid='delete-account-password']", + ) as HTMLInputElement; passwordInput.value = "wrongpassword"; - const confirmBtn = container.querySelector("[data-testid='delete-account-confirm']") as HTMLButtonElement; + const confirmBtn = container.querySelector( + "[data-testid='delete-account-confirm']", + ) as HTMLButtonElement; confirmBtn.click(); // Wait for the rejected promise to settle @@ -613,14 +654,20 @@ describe("SettingsOverlay", () => { const overlay = createSettingsOverlay(defaultOptions); overlay.mount(container); - const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement; + const triggerBtn = container.querySelector( + "[data-testid='delete-account-trigger']", + ) as HTMLElement; triggerBtn.click(); - const passwordInput = container.querySelector("[data-testid='delete-account-password']") as HTMLInputElement; + const passwordInput = container.querySelector( + "[data-testid='delete-account-password']", + ) as HTMLInputElement; passwordInput.value = "typed-something"; // Cancel and reopen - const confirmArea = container.querySelector("[data-testid='delete-account-confirm-area']") as HTMLElement; + const confirmArea = container.querySelector( + "[data-testid='delete-account-confirm-area']", + ) as HTMLElement; const cancelBtn = confirmArea.querySelector("button:not(.account-delete-btn)") as HTMLElement; cancelBtn.click(); triggerBtn.click(); @@ -663,8 +710,9 @@ describe("SettingsOverlay", () => { editInput.value = "A"; // Click Save - const saveBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Save") as HTMLElement; + const saveBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Save", + ) as HTMLElement; saveBtn.click(); // Should NOT call onUpdateProfile @@ -683,8 +731,9 @@ describe("SettingsOverlay", () => { const editInput = container.querySelector("input.form-input[type='text']") as HTMLInputElement; editInput.value = "AB"; - const saveBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Save") as HTMLElement; + const saveBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Save", + ) as HTMLElement; saveBtn.click(); expect(defaultOptions.onUpdateProfile).toHaveBeenCalledWith("AB"); @@ -764,7 +813,9 @@ describe("SettingsOverlay", () => { const tabNames = Array.from(tabs).map((t) => t.textContent); expect(tabNames).not.toContain("Account"); // Should start on Appearance instead - const activeTab = container.querySelector(".settings-sidebar > button.settings-nav-item.active"); + const activeTab = container.querySelector( + ".settings-sidebar > button.settings-nav-item.active", + ); expect(activeTab?.textContent).toBe("Appearance"); overlay.destroy?.(); diff --git a/Client/tauri-client/tests/unit/sidebar-dm-helpers.test.ts b/Client/tauri-client/tests/unit/sidebar-dm-helpers.test.ts index c6e6affd..fce393a8 100644 --- a/Client/tauri-client/tests/unit/sidebar-dm-helpers.test.ts +++ b/Client/tauri-client/tests/unit/sidebar-dm-helpers.test.ts @@ -216,7 +216,9 @@ describe("SidebarDmHelpers", () => { it("sets activeDmUserId in UI store", () => { const deps = makeDeps(); - const dm = makeDmChannel({ recipient: { id: 10, username: "Alice", avatar: "", status: "online" } }); + const dm = makeDmChannel({ + recipient: { id: 10, username: "Alice", avatar: "", status: "online" }, + }); selectDmConversation(dm, deps); expect(uiStore.getState().activeDmUserId).toBe(10); @@ -278,7 +280,9 @@ describe("SidebarDmHelpers", () => { // Add a member with a known status membersStore.setState((prev) => ({ ...prev, - members: new Map([[20, { id: 20, username: "Bob", avatar: null, role: "member", status: "idle" as const }]]), + members: new Map([ + [20, { id: 20, username: "Bob", avatar: null, role: "member", status: "idle" as const }], + ]), })); const mockApi = { @@ -365,13 +369,15 @@ describe("SidebarDmHelpers", () => { }); it("maps DM channels to DmConversation objects", () => { - addDmChannel(makeDmChannel({ - channelId: 100, - recipient: { id: 10, username: "Alice", avatar: "alice.png", status: "online" }, - lastMessage: "Hello!", - lastMessageAt: "2025-01-01T00:00:00Z", - unreadCount: 3, - })); + addDmChannel( + makeDmChannel({ + channelId: 100, + recipient: { id: 10, username: "Alice", avatar: "alice.png", status: "online" }, + lastMessage: "Hello!", + lastMessageAt: "2025-01-01T00:00:00Z", + unreadCount: 3, + }), + ); const result = buildDmConversations(null); expect(result).toHaveLength(1); @@ -388,74 +394,95 @@ describe("SidebarDmHelpers", () => { }); it("marks conversation as active when userId matches activeDmUserId", () => { - addDmChannel(makeDmChannel({ - channelId: 100, - recipient: { id: 10, username: "Alice", avatar: "", status: "online" }, - })); + addDmChannel( + makeDmChannel({ + channelId: 100, + recipient: { id: 10, username: "Alice", avatar: "", status: "online" }, + }), + ); const result = buildDmConversations(10); expect(result[0]!.active).toBe(true); }); it("does not mark conversation as active when userId does not match", () => { - addDmChannel(makeDmChannel({ - channelId: 100, - recipient: { id: 10, username: "Alice", avatar: "", status: "online" }, - })); + addDmChannel( + makeDmChannel({ + channelId: 100, + recipient: { id: 10, username: "Alice", avatar: "", status: "online" }, + }), + ); const result = buildDmConversations(999); expect(result[0]!.active).toBe(false); }); it("uses 'No messages yet' when lastMessage is empty", () => { - addDmChannel(makeDmChannel({ - channelId: 100, - lastMessage: "", - })); + addDmChannel( + makeDmChannel({ + channelId: 100, + lastMessage: "", + }), + ); const result = buildDmConversations(null); expect(result[0]!.lastMessage).toBe("No messages yet"); }); it("sets unread to false when unreadCount is 0", () => { - addDmChannel(makeDmChannel({ - channelId: 100, - unreadCount: 0, - })); + addDmChannel( + makeDmChannel({ + channelId: 100, + unreadCount: 0, + }), + ); const result = buildDmConversations(null); expect(result[0]!.unread).toBe(false); }); it("uses avatar null when avatar is empty string", () => { - addDmChannel(makeDmChannel({ - channelId: 100, - recipient: { id: 10, username: "Alice", avatar: "", status: "online" }, - })); + addDmChannel( + makeDmChannel({ + channelId: 100, + recipient: { id: 10, username: "Alice", avatar: "", status: "online" }, + }), + ); const result = buildDmConversations(null); expect(result[0]!.avatar).toBeNull(); }); it("defaults status to 'offline' when status is undefined", () => { - addDmChannel(makeDmChannel({ - channelId: 100, - recipient: { id: 10, username: "Alice", avatar: "", status: undefined as unknown as string }, - })); + addDmChannel( + makeDmChannel({ + channelId: 100, + recipient: { + id: 10, + username: "Alice", + avatar: "", + status: undefined as unknown as string, + }, + }), + ); const result = buildDmConversations(null); expect(result[0]!.status).toBe("offline"); }); it("handles multiple DM channels", () => { - addDmChannel(makeDmChannel({ - channelId: 100, - recipient: { id: 10, username: "Alice", avatar: "", status: "online" }, - })); - addDmChannel(makeDmChannel({ - channelId: 101, - recipient: { id: 11, username: "Bob", avatar: "", status: "idle" }, - })); + addDmChannel( + makeDmChannel({ + channelId: 100, + recipient: { id: 10, username: "Alice", avatar: "", status: "online" }, + }), + ); + addDmChannel( + makeDmChannel({ + channelId: 101, + recipient: { id: 11, username: "Bob", avatar: "", status: "idle" }, + }), + ); const result = buildDmConversations(11); expect(result).toHaveLength(2); diff --git a/Client/tauri-client/tests/unit/store.test.ts b/Client/tauri-client/tests/unit/store.test.ts index d97e9114..7b2227ad 100644 --- a/Client/tauri-client/tests/unit/store.test.ts +++ b/Client/tauri-client/tests/unit/store.test.ts @@ -1,30 +1,30 @@ -import { describe, it, expect, vi } from 'vitest'; -import { createStore, shallowEqual } from '../../src/lib/store'; +import { describe, it, expect, vi } from "vitest"; +import { createStore, shallowEqual } from "../../src/lib/store"; interface TestState { count: number; name: string; } -const initialState: TestState = { count: 0, name: 'test' }; +const initialState: TestState = { count: 0, name: "test" }; function freshStore() { return createStore({ ...initialState }); } -describe('createStore', () => { - it('getState returns initial state', () => { +describe("createStore", () => { + it("getState returns initial state", () => { const store = freshStore(); - expect(store.getState()).toEqual({ count: 0, name: 'test' }); + expect(store.getState()).toEqual({ count: 0, name: "test" }); }); - it('setState updates state via updater function', () => { + it("setState updates state via updater function", () => { const store = freshStore(); store.setState((prev) => ({ ...prev, count: prev.count + 1 })); - expect(store.getState()).toEqual({ count: 1, name: 'test' }); + expect(store.getState()).toEqual({ count: 1, name: "test" }); }); - it('setState calls all subscribers with new state', () => { + it("setState calls all subscribers with new state", () => { const store = freshStore(); const listener1 = vi.fn(); const listener2 = vi.fn(); @@ -35,12 +35,12 @@ describe('createStore', () => { store.flush(); expect(listener1).toHaveBeenCalledTimes(1); - expect(listener1).toHaveBeenCalledWith({ count: 5, name: 'test' }); + expect(listener1).toHaveBeenCalledWith({ count: 5, name: "test" }); expect(listener2).toHaveBeenCalledTimes(1); - expect(listener2).toHaveBeenCalledWith({ count: 5, name: 'test' }); + expect(listener2).toHaveBeenCalledWith({ count: 5, name: "test" }); }); - it('subscribe returns unsubscribe function that works', () => { + it("subscribe returns unsubscribe function that works", () => { const store = freshStore(); const listener = vi.fn(); const unsubscribe = store.subscribe(listener); @@ -56,7 +56,7 @@ describe('createStore', () => { expect(listener).toHaveBeenCalledTimes(1); }); - it('multiple subscribers all get called', () => { + it("multiple subscribers all get called", () => { const store = freshStore(); const calls: number[] = []; store.subscribe(() => calls.push(1)); @@ -69,7 +69,7 @@ describe('createStore', () => { expect(calls).toEqual([1, 2, 3]); }); - it('unsubscribed listener does not get called', () => { + it("unsubscribed listener does not get called", () => { const store = freshStore(); const kept = vi.fn(); const removed = vi.fn(); @@ -85,7 +85,7 @@ describe('createStore', () => { expect(removed).not.toHaveBeenCalled(); }); - it('select derives value from state', () => { + it("select derives value from state", () => { const store = freshStore(); store.setState((prev) => ({ ...prev, count: 42 })); @@ -93,38 +93,38 @@ describe('createStore', () => { const name = store.select((s) => s.name); expect(count).toBe(42); - expect(name).toBe('test'); + expect(name).toBe("test"); }); - it('setState does NOT mutate previous state reference', () => { + it("setState does NOT mutate previous state reference", () => { const store = freshStore(); const before = store.getState(); store.setState((prev) => ({ ...prev, count: prev.count + 1 })); const after = store.getState(); - expect(before).toEqual({ count: 0, name: 'test' }); - expect(after).toEqual({ count: 1, name: 'test' }); + expect(before).toEqual({ count: 0, name: "test" }); + expect(after).toEqual({ count: 1, name: "test" }); expect(before).not.toBe(after); }); - it('subscriber receives new state not old state', () => { + it("subscriber receives new state not old state", () => { const store = freshStore(); const received: TestState[] = []; store.subscribe((s) => received.push(s)); store.setState((prev) => ({ ...prev, count: 7 })); store.flush(); - store.setState((prev) => ({ ...prev, name: 'updated' })); + store.setState((prev) => ({ ...prev, name: "updated" })); store.flush(); expect(received).toEqual([ - { count: 7, name: 'test' }, - { count: 7, name: 'updated' }, + { count: 7, name: "test" }, + { count: 7, name: "updated" }, ]); }); - it('no subscribers means setState still works without crash', () => { + it("no subscribers means setState still works without crash", () => { const store = freshStore(); expect(() => { store.setState((prev) => ({ ...prev, count: 100 })); @@ -137,8 +137,8 @@ describe('createStore', () => { // subscribeSelector // --------------------------------------------------------------------------- -describe('subscribeSelector', () => { - it('fires when selected slice changes', () => { +describe("subscribeSelector", () => { + it("fires when selected slice changes", () => { const store = freshStore(); const listener = vi.fn(); store.subscribeSelector((s) => s.count, listener); @@ -150,19 +150,19 @@ describe('subscribeSelector', () => { expect(listener).toHaveBeenCalledWith(5); }); - it('does NOT fire when selected slice is unchanged', () => { + it("does NOT fire when selected slice is unchanged", () => { const store = freshStore(); const listener = vi.fn(); store.subscribeSelector((s) => s.count, listener); // Change name but not count - store.setState((prev) => ({ ...prev, name: 'updated' })); + store.setState((prev) => ({ ...prev, name: "updated" })); store.flush(); expect(listener).not.toHaveBeenCalled(); }); - it('fires only for the changed slice among multiple selectors', () => { + it("fires only for the changed slice among multiple selectors", () => { const store = freshStore(); const countListener = vi.fn(); const nameListener = vi.fn(); @@ -176,7 +176,7 @@ describe('subscribeSelector', () => { expect(nameListener).not.toHaveBeenCalled(); }); - it('returns unsubscribe function', () => { + it("returns unsubscribe function", () => { const store = freshStore(); const listener = vi.fn(); const unsub = store.subscribeSelector((s) => s.count, listener); @@ -192,7 +192,7 @@ describe('subscribeSelector', () => { expect(listener).toHaveBeenCalledTimes(1); // no new call }); - it('works with custom equality comparator', () => { + it("works with custom equality comparator", () => { const store = freshStore(); const listener = vi.fn(); // Custom comparator: only fire when count changes by more than 5 @@ -212,7 +212,7 @@ describe('subscribeSelector', () => { expect(listener).toHaveBeenCalledWith(10); }); - it('works with microtask batching', () => { + it("works with microtask batching", () => { const store = freshStore(); const listener = vi.fn(); store.subscribeSelector((s) => s.count, listener); @@ -227,30 +227,33 @@ describe('subscribeSelector', () => { expect(listener).toHaveBeenCalledWith(3); }); - it('multiple selectors on the same store work independently', () => { + it("multiple selectors on the same store work independently", () => { const store = freshStore(); const results: string[] = []; - store.subscribeSelector((s) => s.count, (c) => results.push(`count:${c}`)); - store.subscribeSelector((s) => s.name, (n) => results.push(`name:${n}`)); + store.subscribeSelector( + (s) => s.count, + (c) => results.push(`count:${c}`), + ); + store.subscribeSelector( + (s) => s.name, + (n) => results.push(`name:${n}`), + ); - store.setState((prev) => ({ ...prev, count: 1, name: 'updated' })); + store.setState((prev) => ({ ...prev, count: 1, name: "updated" })); store.flush(); - expect(results).toEqual(['count:1', 'name:updated']); + expect(results).toEqual(["count:1", "name:updated"]); }); - it('shallow-equal default prevents firing for structurally identical selectors', () => { + it("shallow-equal default prevents firing for structurally identical selectors", () => { const store = freshStore(); const listener = vi.fn(); // Selector creates a new object ref each time, but shallowEqual // detects that the content is unchanged and skips the notification. - store.subscribeSelector( - (s) => ({ count: s.count }), - listener, - ); + store.subscribeSelector((s) => ({ count: s.count }), listener); // Changing just name does NOT fire because { count: 0 } shallow-equals { count: 0 } - store.setState((prev) => ({ ...prev, name: 'changed' })); + store.setState((prev) => ({ ...prev, name: "changed" })); store.flush(); expect(listener).toHaveBeenCalledTimes(0); @@ -260,7 +263,7 @@ describe('subscribeSelector', () => { expect(listener).toHaveBeenCalledTimes(1); }); - it('allows strict reference equality via custom comparator', () => { + it("allows strict reference equality via custom comparator", () => { const store = freshStore(); const listener = vi.fn(); // Opt in to strict === comparison to get the old behavior @@ -271,7 +274,7 @@ describe('subscribeSelector', () => { ); // New object ref with same content DOES fire with strict === - store.setState((prev) => ({ ...prev, name: 'changed' })); + store.setState((prev) => ({ ...prev, name: "changed" })); store.flush(); expect(listener).toHaveBeenCalledTimes(1); }); @@ -281,118 +284,127 @@ describe('subscribeSelector', () => { // shallowEqual // --------------------------------------------------------------------------- -describe('shallowEqual', () => { - it('returns true for same reference', () => { +describe("shallowEqual", () => { + it("returns true for same reference", () => { const obj = { a: 1 }; expect(shallowEqual(obj, obj)).toBe(true); }); - it('returns true for two identical plain objects', () => { + it("returns true for two identical plain objects", () => { expect(shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true); }); - it('returns false for objects with different values', () => { + it("returns false for objects with different values", () => { expect(shallowEqual({ a: 1 }, { a: 2 })).toBe(false); }); - it('returns false for objects with different keys count', () => { + it("returns false for objects with different keys count", () => { expect(shallowEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); }); - it('returns false when one side is null', () => { + it("returns false when one side is null", () => { expect(shallowEqual(null, { a: 1 })).toBe(false); expect(shallowEqual({ a: 1 }, null)).toBe(false); }); - it('returns true for null === null', () => { + it("returns true for null === null", () => { expect(shallowEqual(null, null)).toBe(true); }); - it('returns false for non-object types', () => { + it("returns false for non-object types", () => { expect(shallowEqual(1, 2)).toBe(false); - expect(shallowEqual('a', 'b')).toBe(false); + expect(shallowEqual("a", "b")).toBe(false); }); - it('returns true for same primitive values', () => { + it("returns true for same primitive values", () => { expect(shallowEqual(42, 42)).toBe(true); - expect(shallowEqual('hello', 'hello')).toBe(true); + expect(shallowEqual("hello", "hello")).toBe(true); }); - it('returns false when comparing object with primitive', () => { + it("returns false when comparing object with primitive", () => { expect(shallowEqual({ a: 1 }, 42 as unknown)).toBe(false); expect(shallowEqual(42 as unknown, { a: 1 })).toBe(false); }); // Map comparison - it('returns true for identical Maps', () => { - const a = new Map([['x', 1], ['y', 2]]); - const b = new Map([['x', 1], ['y', 2]]); + it("returns true for identical Maps", () => { + const a = new Map([ + ["x", 1], + ["y", 2], + ]); + const b = new Map([ + ["x", 1], + ["y", 2], + ]); expect(shallowEqual(a, b)).toBe(true); }); - it('returns false for Maps with different size', () => { - const a = new Map([['x', 1]]); - const b = new Map([['x', 1], ['y', 2]]); + it("returns false for Maps with different size", () => { + const a = new Map([["x", 1]]); + const b = new Map([ + ["x", 1], + ["y", 2], + ]); expect(shallowEqual(a, b)).toBe(false); }); - it('returns false for Maps with different keys', () => { - const a = new Map([['x', 1]]); - const b = new Map([['y', 1]]); + it("returns false for Maps with different keys", () => { + const a = new Map([["x", 1]]); + const b = new Map([["y", 1]]); expect(shallowEqual(a, b)).toBe(false); }); - it('returns false for Maps with different values', () => { - const a = new Map([['x', 1]]); - const b = new Map([['x', 2]]); + it("returns false for Maps with different values", () => { + const a = new Map([["x", 1]]); + const b = new Map([["x", 2]]); expect(shallowEqual(a, b)).toBe(false); }); // Set comparison - it('returns true for identical Sets', () => { + it("returns true for identical Sets", () => { const a = new Set([1, 2, 3]); const b = new Set([1, 2, 3]); expect(shallowEqual(a, b)).toBe(true); }); - it('returns false for Sets with different size', () => { + it("returns false for Sets with different size", () => { const a = new Set([1, 2]); const b = new Set([1, 2, 3]); expect(shallowEqual(a, b)).toBe(false); }); - it('returns false for Sets with different values', () => { + it("returns false for Sets with different values", () => { const a = new Set([1, 2]); const b = new Set([1, 3]); expect(shallowEqual(a, b)).toBe(false); }); // Array comparison - it('returns true for identical arrays', () => { + it("returns true for identical arrays", () => { expect(shallowEqual([1, 2, 3], [1, 2, 3])).toBe(true); }); - it('returns false for arrays with different length', () => { + it("returns false for arrays with different length", () => { expect(shallowEqual([1, 2], [1, 2, 3])).toBe(false); }); - it('returns false for arrays with different elements', () => { + it("returns false for arrays with different elements", () => { expect(shallowEqual([1, 2], [1, 3])).toBe(false); }); - it('returns true for empty arrays', () => { + it("returns true for empty arrays", () => { expect(shallowEqual([], [])).toBe(true); }); - it('returns true for empty Maps', () => { + it("returns true for empty Maps", () => { expect(shallowEqual(new Map(), new Map())).toBe(true); }); - it('returns true for empty Sets', () => { + it("returns true for empty Sets", () => { expect(shallowEqual(new Set(), new Set())).toBe(true); }); - it('returns true for empty objects', () => { + it("returns true for empty objects", () => { expect(shallowEqual({}, {})).toBe(true); }); }); @@ -401,8 +413,8 @@ describe('shallowEqual', () => { // flush edge cases // --------------------------------------------------------------------------- -describe('flush edge cases', () => { - it('flush when no notification is scheduled does nothing', () => { +describe("flush edge cases", () => { + it("flush when no notification is scheduled does nothing", () => { const store = freshStore(); const listener = vi.fn(); store.subscribe(listener); @@ -411,7 +423,7 @@ describe('flush edge cases', () => { expect(listener).not.toHaveBeenCalled(); }); - it('double flush only fires once', () => { + it("double flush only fires once", () => { const store = freshStore(); const listener = vi.fn(); store.subscribe(listener); @@ -422,11 +434,11 @@ describe('flush edge cases', () => { expect(listener).toHaveBeenCalledTimes(1); }); - it('subscribeSelector with Map values uses shallowEqual', () => { + it("subscribeSelector with Map values uses shallowEqual", () => { interface MapState { items: Map; } - const store = createStore({ items: new Map([['a', 1]]) }); + const store = createStore({ items: new Map([["a", 1]]) }); const listener = vi.fn(); store.subscribeSelector((s) => s.items, listener); @@ -436,12 +448,12 @@ describe('flush edge cases', () => { expect(listener).not.toHaveBeenCalled(); // New map with same content -- shallowEqual returns true, so no fire - store.setState((prev) => ({ items: new Map([['a', 1]]) })); + store.setState((prev) => ({ items: new Map([["a", 1]]) })); store.flush(); expect(listener).not.toHaveBeenCalled(); // Different content - store.setState(() => ({ items: new Map([['a', 2]]) })); + store.setState(() => ({ items: new Map([["a", 2]]) })); store.flush(); expect(listener).toHaveBeenCalledTimes(1); }); diff --git a/Client/tauri-client/tests/unit/stored-appearance.test.ts b/Client/tauri-client/tests/unit/stored-appearance.test.ts index 94cd5315..a2417a33 100644 --- a/Client/tauri-client/tests/unit/stored-appearance.test.ts +++ b/Client/tauri-client/tests/unit/stored-appearance.test.ts @@ -1,10 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -const { - mockGetActiveThemeName, - mockRestoreTheme, - mockApplyThemeByName, -} = vi.hoisted(() => ({ +const { mockGetActiveThemeName, mockRestoreTheme, mockApplyThemeByName } = vi.hoisted(() => ({ mockGetActiveThemeName: vi.fn(() => "neon-glow"), mockRestoreTheme: vi.fn(), mockApplyThemeByName: vi.fn(), @@ -121,4 +117,4 @@ describe("applyStoredAppearance", () => { expect(mockRestoreTheme).toHaveBeenCalledTimes(1); expect(mockApplyThemeByName).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/Client/tauri-client/tests/unit/stream-preview.test.ts b/Client/tauri-client/tests/unit/stream-preview.test.ts index 7d8178f2..78382bdb 100644 --- a/Client/tauri-client/tests/unit/stream-preview.test.ts +++ b/Client/tauri-client/tests/unit/stream-preview.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest"; // Mock livekitSession before importing streamPreview -const mockGetRemoteVideoStream = vi.fn<(uid: number, type: "camera" | "screenshare") => MediaStream | null>(); +const mockGetRemoteVideoStream = + vi.fn<(uid: number, type: "camera" | "screenshare") => MediaStream | null>(); vi.mock("@lib/livekitSession", () => ({ - getRemoteVideoStream: (uid: number, type: "camera" | "screenshare") => mockGetRemoteVideoStream(uid, type), + getRemoteVideoStream: (uid: number, type: "camera" | "screenshare") => + mockGetRemoteVideoStream(uid, type), setUserVolume: vi.fn(), getUserVolume: vi.fn(() => 1), })); @@ -27,7 +29,7 @@ beforeAll(() => { /** Get the preview sibling div after a row (preview is inserted as next sibling). */ function getPreview(row: HTMLElement): HTMLElement | null { const next = row.nextElementSibling; - return (next !== null && next.classList.contains("vu-preview")) ? next as HTMLElement : null; + return next !== null && next.classList.contains("vu-preview") ? (next as HTMLElement) : null; } function createRow(userId: number): HTMLElement { diff --git a/Client/tauri-client/tests/unit/tenor.test.ts b/Client/tauri-client/tests/unit/tenor.test.ts index 23c8b085..0891b4a3 100644 --- a/Client/tauri-client/tests/unit/tenor.test.ts +++ b/Client/tauri-client/tests/unit/tenor.test.ts @@ -28,7 +28,11 @@ function tenorResult( title?: string; } = {}, ) { - const { tinygif = `https://media.tenor.com/${id}_tiny.gif`, gif = `https://media.tenor.com/${id}.gif`, title = `Title ${id}` } = overrides; + const { + tinygif = `https://media.tenor.com/${id}_tiny.gif`, + gif = `https://media.tenor.com/${id}.gif`, + title = `Title ${id}`, + } = overrides; const media_formats: Record = {}; if (tinygif !== null) media_formats["tinygif"] = { url: tinygif }; @@ -74,9 +78,7 @@ describe("searchGifs", () => { it("calls the Tenor search endpoint", async () => { mockFetch.mockResolvedValue(okResponse([])); await searchGifs("cats"); - expect(capturedUrl()).toMatch( - /^https:\/\/tenor\.googleapis\.com\/v2\/search/, - ); + expect(capturedUrl()).toMatch(/^https:\/\/tenor\.googleapis\.com\/v2\/search/); }); it("includes the query param q", async () => { @@ -125,9 +127,7 @@ describe("searchGifs", () => { }); it("maps id, title, url (tinygif), and fullUrl (gif) correctly", async () => { - mockFetch.mockResolvedValue( - okResponse([tenorResult("abc123")]), - ); + mockFetch.mockResolvedValue(okResponse([tenorResult("abc123")])); const gifs = await searchGifs("cats"); expect(gifs).toHaveLength(1); expect(gifs[0]).toEqual({ @@ -148,10 +148,7 @@ describe("searchGifs", () => { it("filters out results with no tinygif format", async () => { mockFetch.mockResolvedValue( - okResponse([ - tenorResult("keep"), - tenorResult("drop", { tinygif: null }), - ]), + okResponse([tenorResult("keep"), tenorResult("drop", { tinygif: null })]), ); const gifs = await searchGifs("cats"); expect(gifs).toHaveLength(1); @@ -160,10 +157,7 @@ describe("searchGifs", () => { it("filters out results with no gif format", async () => { mockFetch.mockResolvedValue( - okResponse([ - tenorResult("keep"), - tenorResult("drop", { gif: null }), - ]), + okResponse([tenorResult("keep"), tenorResult("drop", { gif: null })]), ); const gifs = await searchGifs("cats"); expect(gifs).toHaveLength(1); @@ -172,10 +166,7 @@ describe("searchGifs", () => { it("filters out results missing both formats", async () => { mockFetch.mockResolvedValue( - okResponse([ - tenorResult("drop", { tinygif: null, gif: null }), - tenorResult("keep"), - ]), + okResponse([tenorResult("drop", { tinygif: null, gif: null }), tenorResult("keep")]), ); const gifs = await searchGifs("cats"); expect(gifs).toHaveLength(1); @@ -184,10 +175,7 @@ describe("searchGifs", () => { it("returns an empty array when all results lack required formats", async () => { mockFetch.mockResolvedValue( - okResponse([ - tenorResult("x", { tinygif: null }), - tenorResult("y", { gif: null }), - ]), + okResponse([tenorResult("x", { tinygif: null }), tenorResult("y", { gif: null })]), ); const gifs = await searchGifs("cats"); expect(gifs).toEqual([]); @@ -226,9 +214,7 @@ describe("getTrendingGifs", () => { it("calls the Tenor featured endpoint", async () => { mockFetch.mockResolvedValue(okResponse([])); await getTrendingGifs(); - expect(capturedUrl()).toMatch( - /^https:\/\/tenor\.googleapis\.com\/v2\/featured/, - ); + expect(capturedUrl()).toMatch(/^https:\/\/tenor\.googleapis\.com\/v2\/featured/); }); it("does not include a q param", async () => { @@ -282,10 +268,7 @@ describe("getTrendingGifs", () => { it("filters out results with missing tinygif", async () => { mockFetch.mockResolvedValue( - okResponse([ - tenorResult("keep"), - tenorResult("drop", { tinygif: null }), - ]), + okResponse([tenorResult("keep"), tenorResult("drop", { tinygif: null })]), ); const gifs = await getTrendingGifs(); expect(gifs.map((g) => g.id)).toEqual(["keep"]); @@ -293,10 +276,7 @@ describe("getTrendingGifs", () => { it("filters out results with missing gif", async () => { mockFetch.mockResolvedValue( - okResponse([ - tenorResult("keep"), - tenorResult("drop", { gif: null }), - ]), + okResponse([tenorResult("keep"), tenorResult("drop", { gif: null })]), ); const gifs = await getTrendingGifs(); expect(gifs.map((g) => g.id)).toEqual(["keep"]); diff --git a/Client/tauri-client/tests/unit/test-harness.test.ts b/Client/tauri-client/tests/unit/test-harness.test.ts index e1c7bac9..e5b72dd3 100644 --- a/Client/tauri-client/tests/unit/test-harness.test.ts +++ b/Client/tauri-client/tests/unit/test-harness.test.ts @@ -40,8 +40,7 @@ describe("createTestHarness", () => { it("queryAll() returns all matching elements within the container", () => { harness = createTestHarness(); - harness.container.innerHTML = - 'ab'; + harness.container.innerHTML = 'ab'; const els = harness.queryAll(".item"); expect(els.length).toBe(2); @@ -53,7 +52,9 @@ describe("createTestHarness", () => { const btn = document.createElement("button"); btn.className = "click-me"; - btn.addEventListener("click", () => { clicked = true; }); + btn.addEventListener("click", () => { + clicked = true; + }); harness.container.appendChild(btn); harness.click(".click-me"); diff --git a/Client/tauri-client/tests/unit/themes.test.ts b/Client/tauri-client/tests/unit/themes.test.ts index 7edc8e48..5ade2a7d 100644 --- a/Client/tauri-client/tests/unit/themes.test.ts +++ b/Client/tauri-client/tests/unit/themes.test.ts @@ -142,7 +142,7 @@ describe("CSS injection prevention", () => { }); it("should reject property name not starting with --", () => { - applyCustomWithColors({ "background": "#ff0000" }); + applyCustomWithColors({ background: "#ff0000" }); // "background" does not start with "--", so it must not be set expect(document.body.style.getPropertyValue("background")).toBe(""); }); diff --git a/Client/tauri-client/tests/unit/toast-coverage.test.ts b/Client/tauri-client/tests/unit/toast-coverage.test.ts index 303640aa..3381d3c9 100644 --- a/Client/tauri-client/tests/unit/toast-coverage.test.ts +++ b/Client/tauri-client/tests/unit/toast-coverage.test.ts @@ -78,11 +78,7 @@ describe("toast global helper", () => { initToast(container); showToast("Server connected", "success"); - expect(container.show).toHaveBeenCalledWith( - "Server connected", - "success", - undefined, - ); + expect(container.show).toHaveBeenCalledWith("Server connected", "success", undefined); }); it("defaults the type to 'info' when not specified", () => { diff --git a/Client/tauri-client/tests/unit/toast.test.ts b/Client/tauri-client/tests/unit/toast.test.ts index 8ecb27e8..b09a30b7 100644 --- a/Client/tauri-client/tests/unit/toast.test.ts +++ b/Client/tauri-client/tests/unit/toast.test.ts @@ -1,8 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - createToastContainer, - type ToastContainer, -} from "../../src/components/Toast"; +import { createToastContainer, type ToastContainer } from "../../src/components/Toast"; describe("ToastContainer", () => { let container: HTMLDivElement; diff --git a/Client/tauri-client/tests/unit/totp-settings.test.ts b/Client/tauri-client/tests/unit/totp-settings.test.ts index 7747b440..5319cbdf 100644 --- a/Client/tauri-client/tests/unit/totp-settings.test.ts +++ b/Client/tauri-client/tests/unit/totp-settings.test.ts @@ -113,7 +113,9 @@ describe("TOTP Settings", () => { const enableBtn = container.querySelector("[data-testid='totp-enable-btn']") as HTMLElement; enableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; expect(pwInput).not.toBeNull(); // The enable button should be hidden expect(enableBtn.style.display).toBe("none"); @@ -133,8 +135,9 @@ describe("TOTP Settings", () => { enableBtn.click(); // Leave password empty and click Submit - const submitBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Submit") as HTMLElement; + const submitBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Submit", + ) as HTMLElement; submitBtn.click(); const errorEl = container.querySelector("[data-testid='totp-error']") as HTMLElement; @@ -153,11 +156,14 @@ describe("TOTP Settings", () => { const enableBtn = container.querySelector("[data-testid='totp-enable-btn']") as HTMLElement; enableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "mypassword123"; - const submitBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Submit") as HTMLElement; + const submitBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Submit", + ) as HTMLElement; submitBtn.click(); await vi.waitFor(() => { @@ -176,11 +182,14 @@ describe("TOTP Settings", () => { const enableBtn = container.querySelector("[data-testid='totp-enable-btn']") as HTMLElement; enableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "mypassword123"; - const submitBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Submit") as HTMLElement; + const submitBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Submit", + ) as HTMLElement; submitBtn.click(); await vi.waitFor(() => { @@ -201,11 +210,14 @@ describe("TOTP Settings", () => { const enableBtn = container.querySelector("[data-testid='totp-enable-btn']") as HTMLElement; enableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "mypassword123"; - const submitBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Submit") as HTMLElement; + const submitBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Submit", + ) as HTMLElement; submitBtn.click(); await vi.waitFor(() => { @@ -215,9 +227,7 @@ describe("TOTP Settings", () => { // Look for the backup codes text const codeElements = container.querySelectorAll("code"); - const backupCodeEl = Array.from(codeElements).find( - (el) => el.textContent?.includes("code1"), - ); + const backupCodeEl = Array.from(codeElements).find((el) => el.textContent?.includes("code1")); expect(backupCodeEl).not.toBeUndefined(); expect(backupCodeEl!.textContent).toContain("code1"); expect(backupCodeEl!.textContent).toContain("code2"); @@ -235,15 +245,20 @@ describe("TOTP Settings", () => { const enableBtn = container.querySelector("[data-testid='totp-enable-btn']") as HTMLElement; enableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "mypassword123"; - const submitBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Submit") as HTMLElement; + const submitBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Submit", + ) as HTMLElement; submitBtn.click(); await vi.waitFor(() => { - const codeInput = container.querySelector("[data-testid='totp-code-input']") as HTMLInputElement; + const codeInput = container.querySelector( + "[data-testid='totp-code-input']", + ) as HTMLInputElement; expect(codeInput).not.toBeNull(); expect(codeInput.placeholder).toBe("6-digit code"); }); @@ -266,11 +281,14 @@ describe("TOTP Settings", () => { enableBtn.click(); // Step 2: Enter password and submit - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "mypassword123"; - const submitBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Submit") as HTMLElement; + const submitBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Submit", + ) as HTMLElement; submitBtn.click(); // Wait for QR URI to appear @@ -279,7 +297,9 @@ describe("TOTP Settings", () => { }); // Step 3: Enter code and confirm - const codeInput = container.querySelector("[data-testid='totp-code-input']") as HTMLInputElement; + const codeInput = container.querySelector( + "[data-testid='totp-code-input']", + ) as HTMLInputElement; codeInput.value = "123456"; const confirmBtn = container.querySelector("[data-testid='totp-confirm-btn']") as HTMLElement; @@ -303,11 +323,14 @@ describe("TOTP Settings", () => { const enableBtn = container.querySelector("[data-testid='totp-enable-btn']") as HTMLElement; enableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "wrongpassword"; - const submitBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Submit") as HTMLElement; + const submitBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Submit", + ) as HTMLElement; submitBtn.click(); await vi.waitFor(() => { @@ -334,18 +357,23 @@ describe("TOTP Settings", () => { const enableBtn = container.querySelector("[data-testid='totp-enable-btn']") as HTMLElement; enableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "mypassword123"; - const submitBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Submit") as HTMLElement; + const submitBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Submit", + ) as HTMLElement; submitBtn.click(); await vi.waitFor(() => { expect(container.querySelector("[data-testid='totp-qr-uri']")).not.toBeNull(); }); - const codeInput = container.querySelector("[data-testid='totp-code-input']") as HTMLInputElement; + const codeInput = container.querySelector( + "[data-testid='totp-code-input']", + ) as HTMLInputElement; codeInput.value = "000000"; const confirmBtn = container.querySelector("[data-testid='totp-confirm-btn']") as HTMLElement; @@ -359,7 +387,9 @@ describe("TOTP Settings", () => { }); // Confirm button should be re-enabled - const confirmBtnAfter = container.querySelector("[data-testid='totp-confirm-btn']") as HTMLButtonElement; + const confirmBtnAfter = container.querySelector( + "[data-testid='totp-confirm-btn']", + ) as HTMLButtonElement; expect(confirmBtnAfter.disabled).toBe(false); expect(confirmBtnAfter.textContent).toBe("Verify & Activate"); @@ -381,18 +411,23 @@ describe("TOTP Settings", () => { const enableBtn = container.querySelector("[data-testid='totp-enable-btn']") as HTMLElement; enableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "mypassword123"; - const submitBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Submit") as HTMLElement; + const submitBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Submit", + ) as HTMLElement; submitBtn.click(); await vi.waitFor(() => { expect(container.querySelector("[data-testid='totp-qr-uri']")).not.toBeNull(); }); - const codeInput = container.querySelector("[data-testid='totp-code-input']") as HTMLInputElement; + const codeInput = container.querySelector( + "[data-testid='totp-code-input']", + ) as HTMLInputElement; codeInput.value = "123456"; const confirmBtn = container.querySelector("[data-testid='totp-confirm-btn']") as HTMLElement; @@ -401,7 +436,9 @@ describe("TOTP Settings", () => { // After successful confirmation, onEnrolled() is called which re-renders. // Since updateUser sets mockTotpEnabled = true, the re-render should show the disable view. await vi.waitFor(() => { - const statusBadge = container.querySelector("[data-testid='totp-status-badge']") as HTMLElement; + const statusBadge = container.querySelector( + "[data-testid='totp-status-badge']", + ) as HTMLElement; expect(statusBadge.textContent).toBe("Enabled"); }); @@ -448,7 +485,9 @@ describe("TOTP Settings", () => { expect(disableBtn.style.display).toBe("none"); // Password input should appear - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; expect(pwInput).not.toBeNull(); overlay.destroy?.(); @@ -463,11 +502,14 @@ describe("TOTP Settings", () => { const disableBtn = container.querySelector("[data-testid='totp-disable-btn']") as HTMLElement; disableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "mypassword123"; - const confirmBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Confirm Disable") as HTMLElement; + const confirmBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Confirm Disable", + ) as HTMLElement; confirmBtn.click(); await vi.waitFor(() => { @@ -488,11 +530,14 @@ describe("TOTP Settings", () => { const disableBtn = container.querySelector("[data-testid='totp-disable-btn']") as HTMLElement; disableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "wrongpassword"; - const confirmBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Confirm Disable") as HTMLElement; + const confirmBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Confirm Disable", + ) as HTMLElement; confirmBtn.click(); await vi.waitFor(() => { @@ -518,18 +563,19 @@ describe("TOTP Settings", () => { const disableBtn = container.querySelector("[data-testid='totp-disable-btn']") as HTMLElement; disableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "mypassword123"; - const confirmBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Confirm Disable") as HTMLElement; + const confirmBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Confirm Disable", + ) as HTMLElement; confirmBtn.click(); await vi.waitFor(() => { const errorEl = container.querySelector("[data-testid='totp-error']") as HTMLElement; - expect(errorEl.textContent).toBe( - "2FA is required by this server and cannot be disabled", - ); + expect(errorEl.textContent).toBe("2FA is required by this server and cannot be disabled"); }); overlay.destroy?.(); @@ -545,14 +591,17 @@ describe("TOTP Settings", () => { disableBtn.click(); // Confirm area should be visible - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; expect(pwInput).not.toBeNull(); // Find the Cancel button that is a sibling of the "Confirm Disable" button // inside the TOTP section const totpSection = container.querySelector("[data-testid='totp-section']") as HTMLElement; - const cancelBtn = Array.from(totpSection.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Cancel") as HTMLElement; + const cancelBtn = Array.from(totpSection.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Cancel", + ) as HTMLElement; cancelBtn.click(); // Disable button should reappear @@ -577,17 +626,22 @@ describe("TOTP Settings", () => { const disableBtn = container.querySelector("[data-testid='totp-disable-btn']") as HTMLElement; disableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "mypassword123"; - const confirmBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Confirm Disable") as HTMLElement; + const confirmBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Confirm Disable", + ) as HTMLElement; confirmBtn.click(); // After successful disable, onDisabled() is called which re-renders. // Since updateUser sets mockTotpEnabled = false, re-render should show enrollment view. await vi.waitFor(() => { - const statusBadge = container.querySelector("[data-testid='totp-status-badge']") as HTMLElement; + const statusBadge = container.querySelector( + "[data-testid='totp-status-badge']", + ) as HTMLElement; expect(statusBadge.textContent).toBe("Disabled"); }); @@ -619,17 +673,22 @@ describe("TOTP Settings", () => { // Navigate through enable flow const enableBtn = container.querySelector("[data-testid='totp-enable-btn']") as HTMLElement; enableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "mypassword123"; - const submitBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Submit") as HTMLElement; + const submitBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Submit", + ) as HTMLElement; submitBtn.click(); await vi.waitFor(() => { expect(container.querySelector("[data-testid='totp-qr-uri']")).not.toBeNull(); }); - const codeInput = container.querySelector("[data-testid='totp-code-input']") as HTMLInputElement; + const codeInput = container.querySelector( + "[data-testid='totp-code-input']", + ) as HTMLInputElement; codeInput.value = "123456"; (updateUser as ReturnType).mockClear(); @@ -658,12 +717,15 @@ describe("TOTP Settings", () => { const disableBtn = container.querySelector("[data-testid='totp-disable-btn']") as HTMLElement; disableBtn.click(); - const pwInput = container.querySelector("[data-testid='totp-password-input']") as HTMLInputElement; + const pwInput = container.querySelector( + "[data-testid='totp-password-input']", + ) as HTMLInputElement; pwInput.value = "mypassword123"; (updateUser as ReturnType).mockClear(); - const confirmBtn = Array.from(container.querySelectorAll(".ac-btn")) - .find((b) => b.textContent === "Confirm Disable") as HTMLElement; + const confirmBtn = Array.from(container.querySelectorAll(".ac-btn")).find( + (b) => b.textContent === "Confirm Disable", + ) as HTMLElement; confirmBtn.click(); await vi.waitFor(() => { diff --git a/Client/tauri-client/tests/unit/types.test.ts b/Client/tauri-client/tests/unit/types.test.ts index 09a5acb9..29d9b5e4 100644 --- a/Client/tauri-client/tests/unit/types.test.ts +++ b/Client/tauri-client/tests/unit/types.test.ts @@ -25,21 +25,27 @@ const sampleReady = { payload: { channels: [ { - id: 1, name: "general", type: "text" as const, - category: "Main", position: 0, unread_count: 3, last_message_id: 1040, + id: 1, + name: "general", + type: "text" as const, + category: "Main", + position: 0, + unread_count: 3, + last_message_id: 1040, }, { - id: 10, name: "voice-chat", type: "voice" as const, - category: "Main", position: 1, + id: 10, + name: "voice-chat", + type: "voice" as const, + category: "Main", + position: 1, }, ], members: [ { id: 1, username: "alex", avatar: "uuid.png", role: "admin", status: "online" as const }, { id: 2, username: "jordan", avatar: null, role: "member", status: "idle" as const }, ], - voice_states: [ - { channel_id: 10, user_id: 2, muted: false, deafened: false }, - ], + voice_states: [{ channel_id: 10, user_id: 2, muted: false, deafened: false }], roles: [ { id: 1, name: "Owner", color: "#E74C3C", permissions: 2147483647 }, { id: 2, name: "Admin", color: "#F39C12", permissions: 1073741823 }, @@ -51,14 +57,20 @@ const sampleReady = { const sampleChatMessage = { type: "chat_message" as const, payload: { - id: 1042, channel_id: 5, + id: 1042, + channel_id: 5, user: { id: 1, username: "alex", avatar: "uuid.png" }, content: "Hello everyone!", reply_to: null, - attachments: [{ - id: "upload-uuid-1", filename: "photo.jpg", - size: 204800, mime: "image/jpeg", url: "/files/upload-uuid-1", - }], + attachments: [ + { + id: "upload-uuid-1", + filename: "photo.jpg", + size: 204800, + mime: "image/jpeg", + url: "/files/upload-uuid-1", + }, + ], timestamp: "2026-03-14T10:30:00Z", }, }; @@ -66,9 +78,13 @@ const sampleChatMessage = { const sampleVoiceConfig = { type: "voice_config" as const, payload: { - channel_id: 10, quality: "medium" as const, bitrate: 64000, - threshold_mode: "forwarding" as const, mixing_threshold: 10, - top_speakers: 3, max_users: 50, + channel_id: 10, + quality: "medium" as const, + bitrate: 64000, + threshold_mode: "forwarding" as const, + mixing_threshold: 10, + top_speakers: 3, + max_users: 50, }, }; @@ -153,13 +169,32 @@ describe("AUDIT Critical: no channel_focus message type", () => { // This test documents that channel_focus is intentionally excluded. // If someone accidentally adds it, this comment serves as a warning. const validTypes = [ - "auth_ok", "auth_error", "ready", "chat_message", "chat_send_ok", - "chat_edited", "chat_deleted", "reaction_update", "typing", "presence", - "channel_create", "channel_update", "channel_delete", - "voice_state", "voice_leave", "voice_config", "voice_speakers", - "voice_offer", "voice_answer", "voice_ice", - "member_join", "member_leave", "member_update", "member_ban", - "server_restart", "error", + "auth_ok", + "auth_error", + "ready", + "chat_message", + "chat_send_ok", + "chat_edited", + "chat_deleted", + "reaction_update", + "typing", + "presence", + "channel_create", + "channel_update", + "channel_delete", + "voice_state", + "voice_leave", + "voice_config", + "voice_speakers", + "voice_offer", + "voice_answer", + "voice_ice", + "member_join", + "member_leave", + "member_update", + "member_ban", + "server_restart", + "error", ]; expect(validTypes).not.toContain("channel_focus"); }); @@ -219,7 +254,7 @@ describe("Permission bitfield", () => { }); it("ADMINISTRATOR bit can be checked with bitwise AND", () => { - const ownerPerms = 0x7FFFFFFF; + const ownerPerms = 0x7fffffff; expect(ownerPerms & P.ADMINISTRATOR).toBeTruthy(); const memberPerms = 0x00000663; diff --git a/Client/tauri-client/tests/unit/ui.store.test.ts b/Client/tauri-client/tests/unit/ui.store.test.ts index 1b6513a9..018903a9 100644 --- a/Client/tauri-client/tests/unit/ui.store.test.ts +++ b/Client/tauri-client/tests/unit/ui.store.test.ts @@ -312,28 +312,19 @@ describe("ui store", () => { }); it("resets to empty set for invalid JSON", () => { - localStorage.setItem( - "owncord:collapsed:test-server:443", - "not-json", - ); + localStorage.setItem("owncord:collapsed:test-server:443", "not-json"); loadCollapsedCategories("test-server:443"); expect(uiStore.getState().collapsedCategories.size).toBe(0); }); it("resets to empty set for non-array data", () => { - localStorage.setItem( - "owncord:collapsed:test-server:443", - JSON.stringify({ foo: "bar" }), - ); + localStorage.setItem("owncord:collapsed:test-server:443", JSON.stringify({ foo: "bar" })); loadCollapsedCategories("test-server:443"); expect(uiStore.getState().collapsedCategories.size).toBe(0); }); it("resets to empty set for array with non-string elements", () => { - localStorage.setItem( - "owncord:collapsed:test-server:443", - JSON.stringify([1, 2, 3]), - ); + localStorage.setItem("owncord:collapsed:test-server:443", JSON.stringify([1, 2, 3])); loadCollapsedCategories("test-server:443"); expect(uiStore.getState().collapsedCategories.size).toBe(0); }); diff --git a/Client/tauri-client/tests/unit/user-bar.test.ts b/Client/tauri-client/tests/unit/user-bar.test.ts index 16a57c82..2c3b08ae 100644 --- a/Client/tauri-client/tests/unit/user-bar.test.ts +++ b/Client/tauri-client/tests/unit/user-bar.test.ts @@ -8,15 +8,10 @@ vi.mock("@stores/ui.store", () => ({ uiStore: { getState: () => ({}), subscribe: () => () => {} }, })); -function setAuthState( - user: { username: string } | null, - isAuthenticated: boolean, -): void { +function setAuthState(user: { username: string } | null, isAuthenticated: boolean): void { authStore.setState(() => ({ token: isAuthenticated ? "tok" : null, - user: user !== null - ? { id: 1, username: user.username, avatar: null, role: "member" } - : null, + user: user !== null ? { id: 1, username: user.username, avatar: null, role: "member" } : null, serverName: "TestServer", motd: null, isAuthenticated, @@ -128,7 +123,9 @@ describe("UserBar", () => { comp = createUserBar({ onDisconnect }); comp.mount(container); - const disconnectBtn = container.querySelector('[data-testid="disconnect-btn"]') as HTMLButtonElement; + const disconnectBtn = container.querySelector( + '[data-testid="disconnect-btn"]', + ) as HTMLButtonElement; expect(disconnectBtn).not.toBeNull(); expect(disconnectBtn.getAttribute("aria-label")).toBe("Switch server"); }); @@ -139,7 +136,9 @@ describe("UserBar", () => { comp = createUserBar({ onDisconnect }); comp.mount(container); - const disconnectBtn = container.querySelector('[data-testid="disconnect-btn"]') as HTMLButtonElement; + const disconnectBtn = container.querySelector( + '[data-testid="disconnect-btn"]', + ) as HTMLButtonElement; disconnectBtn.click(); expect(onDisconnect).toHaveBeenCalledOnce(); }); diff --git a/Client/tauri-client/tests/unit/video-grid.test.ts b/Client/tauri-client/tests/unit/video-grid.test.ts index c782489b..ed02764e 100644 --- a/Client/tauri-client/tests/unit/video-grid.test.ts +++ b/Client/tauri-client/tests/unit/video-grid.test.ts @@ -53,9 +53,15 @@ describe("VideoGrid", () => { vi.clearAllMocks(); // ResizeObserver is not available in JSDOM globalThis.ResizeObserver ??= class { - observe(): void { /* noop */ } - unobserve(): void { /* noop */ } - disconnect(): void { /* noop */ } + observe(): void { + /* noop */ + } + unobserve(): void { + /* noop */ + } + disconnect(): void { + /* noop */ + } } as unknown as typeof ResizeObserver; container = document.createElement("div"); diff --git a/Client/tauri-client/tests/unit/video-mode-controller.test.ts b/Client/tauri-client/tests/unit/video-mode-controller.test.ts index bd3b2609..f7accb6e 100644 --- a/Client/tauri-client/tests/unit/video-mode-controller.test.ts +++ b/Client/tauri-client/tests/unit/video-mode-controller.test.ts @@ -4,11 +4,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Mocks // --------------------------------------------------------------------------- -const { mockVoiceStoreGetState, mockGetLocalCameraStream, mockGetLocalScreenshareStream } = vi.hoisted(() => ({ - mockVoiceStoreGetState: vi.fn(), - mockGetLocalCameraStream: vi.fn((): MediaStream | null => null), - mockGetLocalScreenshareStream: vi.fn((): MediaStream | null => null), -})); +const { mockVoiceStoreGetState, mockGetLocalCameraStream, mockGetLocalScreenshareStream } = + vi.hoisted(() => ({ + mockVoiceStoreGetState: vi.fn(), + mockGetLocalCameraStream: vi.fn((): MediaStream | null => null), + mockGetLocalScreenshareStream: vi.fn((): MediaStream | null => null), + })); vi.mock("@stores/voice.store", () => ({ voiceStore: { getState: mockVoiceStoreGetState }, @@ -54,7 +55,10 @@ interface VoiceStateStub { currentChannelId: number | null; localCamera: boolean; localScreenshare: boolean; - voiceUsers: Map>; + voiceUsers: Map< + number, + Map + >; } function makeVoiceState(overrides: Partial = {}): VoiceStateStub { @@ -181,7 +185,11 @@ describe("createVideoModeController", () => { }); ctrl.checkVideoMode(); - expect(vg.addStream).toHaveBeenCalledWith(1, "me (You)", fakeStream, { isSelf: true, audioUserId: 1, isScreenshare: false }); + expect(vg.addStream).toHaveBeenCalledWith(1, "me (You)", fakeStream, { + isSelf: true, + audioUserId: 1, + isScreenshare: false, + }); }); it("removes local tile when local camera is off", () => { @@ -305,7 +313,11 @@ describe("createVideoModeController", () => { ctrl.checkVideoMode(); // screenshareUserId = currentUserId + 1_000_000 = 1 + 1_000_000 = 1_000_001 - expect(vg.addStream).toHaveBeenCalledWith(1_000_001, "me (Screen)", fakeStream, { isSelf: true, audioUserId: 1, isScreenshare: true }); + expect(vg.addStream).toHaveBeenCalledWith(1_000_001, "me (Screen)", fakeStream, { + isSelf: true, + audioUserId: 1, + isScreenshare: true, + }); }); it("removes local screenshare tile when screenshare is turned off", () => { @@ -313,7 +325,11 @@ describe("createVideoModeController", () => { // First call: screenshare on — tile added mockVoiceStoreGetState.mockReturnValue( - makeVoiceState({ currentChannelId: 10, localScreenshare: true, voiceUsers: new Map([[10, users]]) }), + makeVoiceState({ + currentChannelId: 10, + localScreenshare: true, + voiceUsers: new Map([[10, users]]), + }), ); const fakeStream = { getTracks: () => [] } as unknown as MediaStream; mockGetLocalScreenshareStream.mockReturnValue(fakeStream); @@ -325,11 +341,19 @@ describe("createVideoModeController", () => { getCurrentUserId: () => 1, }); ctrl.checkVideoMode(); - expect(vg.addStream).toHaveBeenCalledWith(1_000_001, "me (Screen)", fakeStream, { isSelf: true, audioUserId: 1, isScreenshare: true }); + expect(vg.addStream).toHaveBeenCalledWith(1_000_001, "me (Screen)", fakeStream, { + isSelf: true, + audioUserId: 1, + isScreenshare: true, + }); // Second call: screenshare off — tile removed mockVoiceStoreGetState.mockReturnValue( - makeVoiceState({ currentChannelId: 10, localScreenshare: false, voiceUsers: new Map([[10, users]]) }), + makeVoiceState({ + currentChannelId: 10, + localScreenshare: false, + voiceUsers: new Map([[10, users]]), + }), ); ctrl.checkVideoMode(); expect(vg.removeStream).toHaveBeenCalledWith(1_000_001); diff --git a/Client/tauri-client/tests/unit/voice-audio-tab.test.ts b/Client/tauri-client/tests/unit/voice-audio-tab.test.ts index 61db5b8c..d9753185 100644 --- a/Client/tauri-client/tests/unit/voice-audio-tab.test.ts +++ b/Client/tauri-client/tests/unit/voice-audio-tab.test.ts @@ -24,24 +24,27 @@ describe("VoiceAudioTab camera preview", () => { document.body.innerHTML = ""; localStorage.setItem("owncord:settings:videoInputDevice", '"camera-1"'); - vi.stubGlobal("AudioContext", class { - createAnalyser() { - return { - fftSize: 0, - smoothingTimeConstant: 0, - frequencyBinCount: 32, - getByteFrequencyData: vi.fn(), - }; - } + vi.stubGlobal( + "AudioContext", + class { + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + frequencyBinCount: 32, + getByteFrequencyData: vi.fn(), + }; + } - createMediaStreamSource() { - return { connect: vi.fn() }; - } + createMediaStreamSource() { + return { connect: vi.fn() }; + } - close() { - return Promise.resolve(); - } - }); + close() { + return Promise.resolve(); + } + }, + ); }); it("does not restore a stale camera stream after the tab is aborted", async () => { @@ -56,9 +59,9 @@ describe("VoiceAudioTab camera preview", () => { vi.stubGlobal("navigator", { mediaDevices: { - enumerateDevices: vi.fn().mockResolvedValue([ - { kind: "videoinput", deviceId: "camera-1", label: "Camera 1" }, - ]), + enumerateDevices: vi + .fn() + .mockResolvedValue([{ kind: "videoinput", deviceId: "camera-1", label: "Camera 1" }]), getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => { if (constraints.video && constraints.audio === false) { return new Promise((resolve) => { @@ -97,9 +100,9 @@ describe("VoiceAudioTab camera preview", () => { vi.stubGlobal("navigator", { mediaDevices: { - enumerateDevices: vi.fn().mockResolvedValue([ - { kind: "videoinput", deviceId: "camera-1", label: "Camera 1" }, - ]), + enumerateDevices: vi + .fn() + .mockResolvedValue([{ kind: "videoinput", deviceId: "camera-1", label: "Camera 1" }]), getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => { if (constraints.video && constraints.audio === false) { return new Promise((resolve) => { @@ -132,7 +135,9 @@ describe("VoiceAudioTab camera preview", () => { // --------------------------------------------------------------------------- describe("VoiceAudioTab UI structure", () => { - function stubNavigator(devices: Array<{ kind: string; deviceId: string; label: string }> = []): void { + function stubNavigator( + devices: Array<{ kind: string; deviceId: string; label: string }> = [], + ): void { const audioStream = { getTracks: () => [{ stop: vi.fn() }], } as unknown as MediaStream; @@ -149,16 +154,25 @@ describe("VoiceAudioTab UI structure", () => { vi.clearAllMocks(); localStorage.clear(); document.body.innerHTML = ""; - vi.stubGlobal("AudioContext", class { - createAnalyser() { - return { - fftSize: 0, smoothingTimeConstant: 0, frequencyBinCount: 32, - getByteFrequencyData: vi.fn(), - }; - } - createMediaStreamSource() { return { connect: vi.fn() }; } - close() { return Promise.resolve(); } - }); + vi.stubGlobal( + "AudioContext", + class { + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + frequencyBinCount: 32, + getByteFrequencyData: vi.fn(), + }; + } + createMediaStreamSource() { + return { connect: vi.fn() }; + } + close() { + return Promise.resolve(); + } + }, + ); }); afterEach(() => { @@ -287,9 +301,7 @@ describe("VoiceAudioTab UI structure", () => { }); it("input device change calls switchInputDevice and saves pref", async () => { - stubNavigator([ - { kind: "audioinput", deviceId: "mic-1", label: "Mic 1" }, - ]); + stubNavigator([{ kind: "audioinput", deviceId: "mic-1", label: "Mic 1" }]); const ac = new AbortController(); const tab = createVoiceAudioTab(ac.signal); const el = tab.build(); @@ -309,9 +321,7 @@ describe("VoiceAudioTab UI structure", () => { }); it("output device change calls switchOutputDevice and saves pref", async () => { - stubNavigator([ - { kind: "audiooutput", deviceId: "spk-1", label: "Speaker 1" }, - ]); + stubNavigator([{ kind: "audiooutput", deviceId: "spk-1", label: "Speaker 1" }]); const ac = new AbortController(); const tab = createVoiceAudioTab(ac.signal); const el = tab.build(); @@ -393,8 +403,8 @@ describe("VoiceAudioTab UI structure", () => { const inputSelect = el.querySelectorAll("select")[0]!; const options = inputSelect.querySelectorAll("option"); // Should have default + error option - const texts = Array.from(options).map(o => o.textContent); - expect(texts.some(t => t?.includes("Could not enumerate"))).toBe(true); + const texts = Array.from(options).map((o) => o.textContent); + expect(texts.some((t) => t?.includes("Could not enumerate"))).toBe(true); }); ac.abort(); @@ -425,9 +435,9 @@ describe("VoiceAudioTab UI structure", () => { vi.stubGlobal("navigator", { mediaDevices: { - enumerateDevices: vi.fn().mockResolvedValue([ - { kind: "videoinput", deviceId: "cam-1", label: "Camera 1" }, - ]), + enumerateDevices: vi + .fn() + .mockResolvedValue([{ kind: "videoinput", deviceId: "cam-1", label: "Camera 1" }]), getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => { if (constraints.video && constraints.audio === false) { return Promise.resolve(cameraStream); @@ -459,9 +469,9 @@ describe("VoiceAudioTab UI structure", () => { vi.stubGlobal("navigator", { mediaDevices: { - enumerateDevices: vi.fn().mockResolvedValue([ - { kind: "videoinput", deviceId: "cam-1", label: "Camera 1" }, - ]), + enumerateDevices: vi + .fn() + .mockResolvedValue([{ kind: "videoinput", deviceId: "cam-1", label: "Camera 1" }]), getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => { if (constraints.video && constraints.audio === false) { return Promise.resolve(cameraStream); @@ -502,9 +512,9 @@ describe("VoiceAudioTab UI structure", () => { vi.stubGlobal("navigator", { mediaDevices: { - enumerateDevices: vi.fn().mockResolvedValue([ - { kind: "videoinput", deviceId: "cam-1", label: "Camera 1" }, - ]), + enumerateDevices: vi + .fn() + .mockResolvedValue([{ kind: "videoinput", deviceId: "cam-1", label: "Camera 1" }]), getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => { if (constraints.video && constraints.audio === false) { return Promise.reject(new Error("Camera access denied")); @@ -575,7 +585,7 @@ describe("VoiceAudioTab UI structure", () => { document.body.appendChild(el); // Should not throw — mic meter stays empty - await new Promise(r => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); ac.abort(); }); @@ -601,9 +611,7 @@ describe("VoiceAudioTab UI structure", () => { }); it("uses device ID fallback label for devices without labels", async () => { - stubNavigator([ - { kind: "audioinput", deviceId: "abcdef12", label: "" }, - ]); + stubNavigator([{ kind: "audioinput", deviceId: "abcdef12", label: "" }]); const ac = new AbortController(); const tab = createVoiceAudioTab(ac.signal); const el = tab.build(); @@ -664,4 +672,4 @@ describe("VoiceAudioTab UI structure", () => { expect(() => tab.build()).not.toThrow(); ac.abort(); }); -}); \ No newline at end of file +}); diff --git a/Client/tauri-client/tests/unit/voice-channel.test.ts b/Client/tauri-client/tests/unit/voice-channel.test.ts index 45ac0acb..8b93f13c 100644 --- a/Client/tauri-client/tests/unit/voice-channel.test.ts +++ b/Client/tauri-client/tests/unit/voice-channel.test.ts @@ -421,9 +421,7 @@ describe("VoiceChannel", () => { const userItems = result.element.querySelectorAll(".voice-user-item"); expect(userItems.length).toBe(2); - const names = Array.from(userItems).map( - (el) => el.querySelector(".vu-name")?.textContent, - ); + const names = Array.from(userItems).map((el) => el.querySelector(".vu-name")?.textContent); expect(names).toContain("Alice"); expect(names).toContain("Bob"); @@ -573,11 +571,13 @@ describe("VoiceChannel", () => { container.appendChild(result.element); const userRow = result.element.querySelector(".voice-user-item") as HTMLElement; - userRow.dispatchEvent(new MouseEvent("contextmenu", { - bubbles: true, - clientX: 200, - clientY: 300, - })); + userRow.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + clientX: 200, + clientY: 300, + }), + ); // Menu should be appended to document.body const menu = document.body.querySelector(".context-menu"); @@ -629,11 +629,13 @@ describe("VoiceChannel", () => { container.appendChild(result.element); const userRow = result.element.querySelector(".voice-user-item") as HTMLElement; - userRow.dispatchEvent(new MouseEvent("contextmenu", { - bubbles: true, - clientX: 200, - clientY: 300, - })); + userRow.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + clientX: 200, + clientY: 300, + }), + ); // No context menu should appear const menu = document.body.querySelector(".context-menu"); @@ -675,11 +677,13 @@ describe("VoiceChannel", () => { // Open context menu const userRow = result.element.querySelector(".voice-user-item") as HTMLElement; - userRow.dispatchEvent(new MouseEvent("contextmenu", { - bubbles: true, - clientX: 200, - clientY: 300, - })); + userRow.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + clientX: 200, + clientY: 300, + }), + ); expect(document.body.querySelector(".context-menu")).not.toBeNull(); diff --git a/Client/tauri-client/tests/unit/voice-disconnect.test.ts b/Client/tauri-client/tests/unit/voice-disconnect.test.ts index bfa97f1a..44d40a10 100644 --- a/Client/tauri-client/tests/unit/voice-disconnect.test.ts +++ b/Client/tauri-client/tests/unit/voice-disconnect.test.ts @@ -120,8 +120,12 @@ describe("Voice disconnect — logout cleanup", () => { const wsDisconnect = vi.fn(); const callOrder: string[] = []; - wsSend.mockImplementation(() => { callOrder.push("send"); }); - wsDisconnect.mockImplementation(() => { callOrder.push("disconnect"); }); + wsSend.mockImplementation(() => { + callOrder.push("send"); + }); + wsDisconnect.mockImplementation(() => { + callOrder.push("disconnect"); + }); // User is authenticated and in a voice channel authStore.setState((prev) => ({ diff --git a/Client/tauri-client/tests/unit/voice-widget.test.ts b/Client/tauri-client/tests/unit/voice-widget.test.ts index d55d2689..8338789a 100644 --- a/Client/tauri-client/tests/unit/voice-widget.test.ts +++ b/Client/tauri-client/tests/unit/voice-widget.test.ts @@ -16,8 +16,14 @@ vi.mock("@lib/connectionStats", () => ({ start: vi.fn(), stop: vi.fn(), getStats: vi.fn().mockReturnValue({ - rtt: 0, quality: "excellent", outRate: 0, inRate: 0, - outPackets: 0, inPackets: 0, totalUp: 0, totalDown: 0, + rtt: 0, + quality: "excellent", + outRate: 0, + inRate: 0, + outPackets: 0, + inPackets: 0, + totalUp: 0, + totalDown: 0, }), onUpdate: vi.fn().mockReturnValue(() => {}), onQualityChanged: vi.fn().mockReturnValue(() => {}), @@ -281,7 +287,9 @@ describe("VoiceWidget", () => { }); widget.mount(container); - const screenshareBtn = container.querySelector('[aria-label="Screenshare"]') as HTMLButtonElement; + const screenshareBtn = container.querySelector( + '[aria-label="Screenshare"]', + ) as HTMLButtonElement; expect(screenshareBtn).not.toBeNull(); expect(screenshareBtn.classList.contains("active-ctrl")).toBe(true); expect(screenshareBtn.getAttribute("aria-pressed")).toBe("true"); diff --git a/Client/tauri-client/tests/unit/voice.store.test.ts b/Client/tauri-client/tests/unit/voice.store.test.ts index 4a321331..61fe6e10 100644 --- a/Client/tauri-client/tests/unit/voice.store.test.ts +++ b/Client/tauri-client/tests/unit/voice.store.test.ts @@ -17,11 +17,7 @@ import { setVoiceConfig, getChannelVoiceUsers, } from "../../src/stores/voice.store"; -import type { - ReadyVoiceState, - VoiceStatePayload, - VoiceLeavePayload, -} from "../../src/lib/types"; +import type { ReadyVoiceState, VoiceStatePayload, VoiceLeavePayload } from "../../src/lib/types"; import { authStore } from "../../src/stores/auth.store"; function resetStore(): void { @@ -503,7 +499,11 @@ describe("voice store", () => { expect(ch10?.has(2)).toBe(true); authStore.setState(() => ({ - token: null, user: null, serverName: null, motd: null, isAuthenticated: false, + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, })); }); @@ -523,7 +523,11 @@ describe("voice store", () => { expect(voiceStore.getState().voiceUsers.has(20)).toBe(false); authStore.setState(() => ({ - token: null, user: null, serverName: null, motd: null, isAuthenticated: false, + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, })); }); }); @@ -544,7 +548,11 @@ describe("voice store", () => { expect(voiceStore.getState().currentChannelId).toBe(10); authStore.setState(() => ({ - token: null, user: null, serverName: null, motd: null, isAuthenticated: false, + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, })); }); @@ -564,7 +572,11 @@ describe("voice store", () => { expect(voiceStore.getState().currentChannelId).toBe(42); authStore.setState(() => ({ - token: null, user: null, serverName: null, motd: null, isAuthenticated: false, + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, })); }); }); diff --git a/Client/tauri-client/tests/unit/ws.test.ts b/Client/tauri-client/tests/unit/ws.test.ts index ec3dd0bd..adeea521 100644 --- a/Client/tauri-client/tests/unit/ws.test.ts +++ b/Client/tauri-client/tests/unit/ws.test.ts @@ -114,14 +114,17 @@ describe("WebSocket Client (Tauri proxy)", () => { const states: ConnectionState[] = []; client.onStateChange((s) => states.push(s)); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", - payload: { - user: { id: 1, username: "alex", avatar: null, role: "admin" }, - server_name: "Test", - motd: "Hello", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + payload: { + user: { id: 1, username: "alex", avatar: null, role: "admin" }, + server_name: "Test", + motd: "Hello", + }, + }), + ); expect(states).toContain("connected"); }); @@ -134,16 +137,21 @@ describe("WebSocket Client (Tauri proxy)", () => { const messages: unknown[] = []; client.on("chat_message", (payload) => messages.push(payload)); - emitTauriEvent("ws-message", JSON.stringify({ - type: "chat_message", - payload: { - id: 1, channel_id: 5, - user: { id: 1, username: "alex", avatar: null }, - content: "Hello", - reply_to: null, attachments: [], - timestamp: "2026-03-14T10:00:00Z", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + payload: { + id: 1, + channel_id: 5, + user: { id: 1, username: "alex", avatar: null }, + content: "Hello", + reply_to: null, + attachments: [], + timestamp: "2026-03-14T10:00:00Z", + }, + }), + ); expect(messages).toHaveLength(1); }); @@ -157,16 +165,21 @@ describe("WebSocket Client (Tauri proxy)", () => { const unsub = client.on("chat_message", (payload) => messages.push(payload)); unsub(); - emitTauriEvent("ws-message", JSON.stringify({ - type: "chat_message", - payload: { - id: 1, channel_id: 5, - user: { id: 1, username: "alex", avatar: null }, - content: "Hello", - reply_to: null, attachments: [], - timestamp: "2026-03-14T10:00:00Z", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + payload: { + id: 1, + channel_id: 5, + user: { id: 1, username: "alex", avatar: null }, + content: "Hello", + reply_to: null, + attachments: [], + timestamp: "2026-03-14T10:00:00Z", + }, + }), + ); expect(messages).toHaveLength(0); }); @@ -179,10 +192,13 @@ describe("WebSocket Client (Tauri proxy)", () => { const authErrors: unknown[] = []; client.on("auth_error", (payload) => authErrors.push(payload)); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_error", - payload: { message: "Invalid token" }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_error", + payload: { message: "Invalid token" }, + }), + ); await vi.advanceTimersByTimeAsync(60_000); @@ -195,13 +211,17 @@ describe("WebSocket Client (Tauri proxy)", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); const states: ConnectionState[] = []; client.onStateChange((s) => states.push(s)); @@ -245,10 +265,12 @@ describe("WebSocket Client (Tauri proxy)", () => { const bigData = JSON.stringify({ type: "chat_message", payload: { - id: 1, channel_id: 1, + id: 1, + channel_id: 1, user: { id: 1, username: "a", avatar: null }, content: "x".repeat(100), - reply_to: null, attachments: [], + reply_to: null, + attachments: [], timestamp: "2026-01-01T00:00:00Z", }, }); @@ -305,7 +327,10 @@ describe("lastSeq tracking", () => { emitTauriEvent("ws-state", "open"); const authCall = mockInvoke.mock.calls.find( - (c) => c[0] === "ws_send" && typeof c[1]?.message === "string" && (c[1].message as string).includes('"type":"auth"'), + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), ); expect(authCall).toBeDefined(); const authMsg = JSON.parse((authCall![1] as { message: string }).message); @@ -318,26 +343,36 @@ describe("lastSeq tracking", () => { emitTauriEvent("ws-state", "open"); // Send auth_ok so we're connected - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", - seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // Send a message with seq 42 - emitTauriEvent("ws-message", JSON.stringify({ - type: "chat_message", - seq: 42, - payload: { - id: 1, channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "hi", reply_to: null, attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 42, + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "hi", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); // Now simulate a disconnect + reconnect to verify lastSeq was updated emitTauriEvent("ws-state", "closed"); @@ -347,7 +382,10 @@ describe("lastSeq tracking", () => { emitTauriEvent("ws-state", "open"); const authCall = mockInvoke.mock.calls.find( - (c) => c[0] === "ws_send" && typeof c[1]?.message === "string" && (c[1].message as string).includes('"type":"auth"'), + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), ); expect(authCall).toBeDefined(); const authMsg = JSON.parse((authCall![1] as { message: string }).message); @@ -359,14 +397,18 @@ describe("lastSeq tracking", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", - seq: 5, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 5, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // Disconnect unexpectedly emitTauriEvent("ws-state", "closed"); @@ -376,7 +418,10 @@ describe("lastSeq tracking", () => { emitTauriEvent("ws-state", "open"); const authCall = mockInvoke.mock.calls.find( - (c) => c[0] === "ws_send" && typeof c[1]?.message === "string" && (c[1].message as string).includes('"type":"auth"'), + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), ); expect(authCall).toBeDefined(); const authMsg = JSON.parse((authCall![1] as { message: string }).message); @@ -388,13 +433,18 @@ describe("lastSeq tracking", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 10, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 10, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // First auto-reconnect emitTauriEvent("ws-state", "closed"); @@ -402,22 +452,34 @@ describe("lastSeq tracking", () => { emitTauriEvent("ws-state", "open"); // Receive more messages with higher seq - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 11, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); - emitTauriEvent("ws-message", JSON.stringify({ - type: "chat_message", seq: 25, - payload: { - id: 2, channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "hello", reply_to: null, attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 11, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 25, + payload: { + id: 2, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "hello", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); // Second auto-reconnect emitTauriEvent("ws-state", "closed"); @@ -426,7 +488,10 @@ describe("lastSeq tracking", () => { emitTauriEvent("ws-state", "open"); const authCall = mockInvoke.mock.calls.find( - (c) => c[0] === "ws_send" && typeof c[1]?.message === "string" && (c[1].message as string).includes('"type":"auth"'), + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), ); const authMsg = JSON.parse((authCall![1] as { message: string }).message); expect(authMsg.payload.last_seq).toBe(25); @@ -437,13 +502,18 @@ describe("lastSeq tracking", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 50, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 50, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // Intentional disconnect (e.g. logout) client.disconnect(); @@ -455,7 +525,10 @@ describe("lastSeq tracking", () => { emitTauriEvent("ws-state", "open"); const authCall = mockInvoke.mock.calls.find( - (c) => c[0] === "ws_send" && typeof c[1]?.message === "string" && (c[1].message as string).includes('"type":"auth"'), + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), ); expect(authCall).toBeDefined(); const authMsg = JSON.parse((authCall![1] as { message: string }).message); @@ -485,13 +558,18 @@ describe("cert mismatch blocking", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // Cert mismatch event fires emitTauriEvent("cert-tofu", { @@ -509,9 +587,7 @@ describe("cert mismatch blocking", () => { // Wait well beyond normal backoff — should NOT reconnect mockInvoke.mockClear(); await vi.advanceTimersByTimeAsync(60_000); - const reconnectCalls = mockInvoke.mock.calls.filter( - (c) => c[0] === "ws_connect", - ); + const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect"); expect(reconnectCalls).toHaveLength(0); }); @@ -520,13 +596,18 @@ describe("cert mismatch blocking", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); emitTauriEvent("cert-tofu", { host: "localhost:8443", @@ -556,13 +637,18 @@ describe("cert mismatch blocking", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // Trigger mismatch emitTauriEvent("cert-tofu", { @@ -647,34 +733,53 @@ describe("message handling edge cases", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 10, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 10, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // seq=50 then seq=30 — should keep 50 - emitTauriEvent("ws-message", JSON.stringify({ - type: "chat_message", seq: 50, - payload: { - id: 1, channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "hi", reply_to: null, attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 50, + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "hi", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); - emitTauriEvent("ws-message", JSON.stringify({ - type: "chat_message", seq: 30, - payload: { - id: 2, channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "hello", reply_to: null, attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 30, + payload: { + id: 2, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "hello", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); // Disconnect and reconnect to verify lastSeq emitTauriEvent("ws-state", "closed"); @@ -683,7 +788,10 @@ describe("message handling edge cases", () => { emitTauriEvent("ws-state", "open"); const authCall = mockInvoke.mock.calls.find( - (c) => c[0] === "ws_send" && typeof c[1]?.message === "string" && (c[1].message as string).includes('"type":"auth"'), + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), ); const authMsg = JSON.parse((authCall![1] as { message: string }).message); expect(authMsg.payload.last_seq).toBe(50); @@ -697,16 +805,22 @@ describe("message handling edge cases", () => { const messages: unknown[] = []; client.on("chat_message", (p) => messages.push(p)); - emitTauriEvent("ws-message", JSON.stringify({ - type: "chat_message", - // no seq field - payload: { - id: 1, channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "no seq", reply_to: null, attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + // no seq field + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "no seq", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); expect(messages).toHaveLength(1); }); @@ -717,13 +831,17 @@ describe("message handling edge cases", () => { emitTauriEvent("ws-state", "open"); // Send a message with no listener registered — should log "no listeners" - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // No crash means the "no listeners" debug log path executed expect(client.getState()).toBe("connected"); @@ -743,15 +861,21 @@ describe("message handling edge cases", () => { const messages: unknown[] = []; client.on("chat_message", (p) => messages.push(p)); - emitTauriEvent("ws-message", JSON.stringify({ - type: "chat_message", - payload: { - id: 1, channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "test", reply_to: null, attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "test", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); // Second listener should still receive the message expect(messages).toHaveLength(1); @@ -847,23 +971,36 @@ describe("reconnection dedup", () => { emitTauriEvent("ws-state", "open"); // Auth and get some messages to advance lastSeq - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); - emitTauriEvent("ws-message", JSON.stringify({ - type: "chat_message", seq: 5, id: "msg-5", - payload: { - id: 1, channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "original", reply_to: null, attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 5, + id: "msg-5", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "original", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); // Disconnect unexpectedly emitTauriEvent("ws-state", "closed"); @@ -879,26 +1016,42 @@ describe("reconnection dedup", () => { client.on("chat_message", (p) => messages.push(p)); // Send a message during replay -- first occurrence passes - emitTauriEvent("ws-message", JSON.stringify({ - type: "chat_message", seq: 5, id: "msg-5", - payload: { - id: 1, channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "original", reply_to: null, attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 5, + id: "msg-5", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "original", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); // Send the SAME message ID again — should be deduped - emitTauriEvent("ws-message", JSON.stringify({ - type: "chat_message", seq: 5, id: "msg-5", - payload: { - id: 1, channel_id: 1, - user: { id: 1, username: "a", avatar: null }, - content: "original", reply_to: null, attachments: [], - timestamp: "2026-01-01T00:00:00Z", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "chat_message", + seq: 5, + id: "msg-5", + payload: { + id: 1, + channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "original", + reply_to: null, + attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }), + ); // Only the first occurrence should pass through expect(messages).toHaveLength(1); @@ -910,13 +1063,18 @@ describe("reconnection dedup", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 5, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 5, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // Disconnect emitTauriEvent("ws-state", "closed"); @@ -929,13 +1087,18 @@ describe("reconnection dedup", () => { client.on("auth_ok", (p) => authPayloads.push(p)); // auth_ok during replay should NOT be deduped - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 6, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 6, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); expect(authPayloads).toHaveLength(1); // After auth_ok, replay dedup should be cleared @@ -947,18 +1110,27 @@ describe("reconnection dedup", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); - emitTauriEvent("ws-message", JSON.stringify({ - type: "presence", seq: 10, - payload: { user_id: 1, status: "idle" }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "presence", + seq: 10, + payload: { user_id: 1, status: "idle" }, + }), + ); // Disconnect emitTauriEvent("ws-state", "closed"); @@ -969,22 +1141,34 @@ describe("reconnection dedup", () => { client.on("presence", (p) => presences.push(p)); // First presence during replay — passes through - emitTauriEvent("ws-message", JSON.stringify({ - type: "presence", seq: 10, - payload: { user_id: 1, status: "idle" }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "presence", + seq: 10, + payload: { user_id: 1, status: "idle" }, + }), + ); // Same type:seq — should be deduped - emitTauriEvent("ws-message", JSON.stringify({ - type: "presence", seq: 10, - payload: { user_id: 1, status: "idle" }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "presence", + seq: 10, + payload: { user_id: 1, status: "idle" }, + }), + ); // Different seq — should pass through - emitTauriEvent("ws-message", JSON.stringify({ - type: "presence", seq: 11, - payload: { user_id: 1, status: "online" }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "presence", + seq: 11, + payload: { user_id: 1, status: "online" }, + }), + ); expect(presences).toHaveLength(2); expect((presences[0] as { status: string }).status).toBe("idle"); @@ -1023,13 +1207,18 @@ describe("heartbeat", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); mockInvoke.mockClear(); @@ -1037,7 +1226,10 @@ describe("heartbeat", () => { await vi.advanceTimersByTimeAsync(30_000); const pingSends = mockInvoke.mock.calls.filter( - (c) => c[0] === "ws_send" && typeof c[1]?.message === "string" && (c[1].message as string).includes('"type":"ping"'), + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"ping"'), ); expect(pingSends.length).toBeGreaterThanOrEqual(1); }); @@ -1047,13 +1239,18 @@ describe("heartbeat", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); client.disconnect(); mockInvoke.mockClear(); @@ -1062,7 +1259,10 @@ describe("heartbeat", () => { await vi.advanceTimersByTimeAsync(60_000); const pingSends = mockInvoke.mock.calls.filter( - (c) => c[0] === "ws_send" && typeof c[1]?.message === "string" && (c[1].message as string).includes('"type":"ping"'), + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"ping"'), ); expect(pingSends).toHaveLength(0); }); @@ -1116,13 +1316,18 @@ describe("send edge cases", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // Drop connection emitTauriEvent("ws-state", "closed"); @@ -1131,13 +1336,18 @@ describe("send edge cases", () => { await vi.advanceTimersByTimeAsync(1100); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 2, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 2, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // Drop again emitTauriEvent("ws-state", "closed"); @@ -1155,13 +1365,18 @@ describe("send edge cases", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // Make ws_send reject mockInvoke.mockImplementation(async (cmd: string) => { @@ -1185,13 +1400,18 @@ describe("send edge cases", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // Make ws_disconnect throw mockInvoke.mockImplementation(async (cmd: string) => { @@ -1214,26 +1434,36 @@ describe("send edge cases", () => { await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: 1, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 1, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); // Force multiple reconnect attempts to ramp up backoff for (let i = 0; i < 5; i++) { emitTauriEvent("ws-state", "closed"); await vi.advanceTimersByTimeAsync(10_000); // well past any backoff emitTauriEvent("ws-state", "open"); - emitTauriEvent("ws-message", JSON.stringify({ - type: "auth_ok", seq: i + 2, - payload: { - user: { id: 1, username: "a", avatar: null, role: "admin" }, - server_name: "S", motd: "", - }, - })); + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: i + 2, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); } // At this point, the reconnect delay should be capped at 5000ms