test: client test updates — fix failures + align with security hardening

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.
This commit is contained in:
jevb
2026-04-01 11:40:31 +02:00
parent 5d8cb32c90
commit 867041b094
112 changed files with 3824 additions and 2553 deletions
@@ -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 = "<span>hello</span>";
document.body.appendChild(div);
const span = document.querySelector("span");
expect(span).not.toBeNull();
expect(span!.textContent).toBe("hello");
div.remove();
});
@@ -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
@@ -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();
});
@@ -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("/");
});
@@ -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 });
});
});
@@ -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("/");
});
+162 -115
View File
@@ -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<void> {
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<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_2FA_RESPONSE },
],
simulateWsFlow: false,
}));
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_2FA_RESPONSE },
],
simulateWsFlow: false,
}),
);
}
export async function mockTauriFullSession(page: Page): Promise<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
{ 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<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES_RICH },
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
{ pattern: "/api/v1/invites", status: 200, body: MOCK_INVITES },
],
simulateWsFlow: true,
readyOverrides: {
channels: MOCK_CHANNELS_WITH_CATEGORIES,
members: MOCK_MEMBERS_MULTI_ROLE,
},
}));
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES_RICH },
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
{ pattern: "/api/v1/invites", status: 200, body: MOCK_INVITES },
],
simulateWsFlow: true,
readyOverrides: {
channels: MOCK_CHANNELS_WITH_CATEGORIES,
members: MOCK_MEMBERS_MULTI_ROLE,
},
}),
);
}
export async function mockTauriFullSessionWithVoice(page: Page): Promise<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
],
simulateWsFlow: true,
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<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
],
simulateWsFlow: true,
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<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
],
simulateWsFlow: true,
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<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES_RICH },
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
{ pattern: "/api/v1/invites", status: 200, body: MOCK_INVITES },
],
simulateWsFlow: true,
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<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 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<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 401, body: { error: "INVALID_CREDENTIALS", message: "Invalid username or password" } },
],
simulateWsFlow: false,
}));
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<vo
* Emit a WebSocket event from the mock server to the client.
* Must be called after the page has loaded and WS listeners are registered.
*/
export async function emitWsEvent(
page: Page,
eventName: string,
payload: unknown,
): Promise<void> {
export async function emitWsEvent(page: Page, eventName: string, payload: unknown): Promise<void> {
await page.evaluate(
({ event, data }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).__tauriEmitEvent(event, typeof data === "string" ? data : JSON.stringify(data));
(window as any).__tauriEmitEvent(
event,
typeof data === "string" ? data : JSON.stringify(data),
);
},
{ event: eventName, data: payload },
);
@@ -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",
@@ -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();
});
@@ -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,
});
});
});
@@ -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,
});
}
});
});
@@ -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("");
@@ -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<void>
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<PersistentSta
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.`,
);
}
@@ -26,10 +26,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);
@@ -69,7 +66,7 @@ async function waitForCdpEndpoint(port: number, timeout: number): Promise<void>
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<NativeFixtures>({
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.`,
);
}
@@ -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" });
@@ -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
@@ -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);
}
});
@@ -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) {
@@ -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(() =>
+11 -9
View File
@@ -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
@@ -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/);
}
@@ -11,23 +11,31 @@ const MOCK_REGISTER_RESPONSE = {
};
async function mockRegisterSuccess(page: import("@playwright/test").Page): Promise<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/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<void> {
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<void> {
@@ -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 }) => {
@@ -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
@@ -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");
+28 -20
View File
@@ -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<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_2FA_RESPONSE },
{ 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<void> {
await page.addInitScript(buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_2FA_RESPONSE },
{ 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<void> {
@@ -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();
}
@@ -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("");
@@ -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 }) => {
+37 -41
View File
@@ -24,9 +24,7 @@ import type {
// ---------------------------------------------------------------------------
/** Create a MessageResponse with sensible defaults. */
export function makeMessage(
overrides?: Partial<MessageResponse>,
): MessageResponse {
export function makeMessage(overrides?: Partial<MessageResponse>): 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>,
): MemberResponse {
export function makeMember(overrides?: Partial<MemberResponse>): 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>,
): ReadyChannel {
export function makeChannel(overrides?: Partial<ReadyChannel>): 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>,
): ReactionSummary {
export function makeReaction(overrides?: Partial<ReactionSummary>): ReactionSummary {
return {
emoji: "👍",
count: 1,
@@ -86,9 +78,7 @@ export function makeReaction(
}
/** Create a VoiceStatePayload with sensible defaults. */
export function makeVoiceState(
overrides?: Partial<VoiceStatePayload>,
): VoiceStatePayload {
export function makeVoiceState(overrides?: Partial<VoiceStatePayload>): 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>,
): MessageUser {
export function makeMessageUser(overrides?: Partial<MessageUser>): 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>,
): Attachment {
export function makeAttachment(overrides?: Partial<Attachment>): 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>,
): ReadyMember {
export function makeReadyMember(overrides?: Partial<ReadyMember>): 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>,
): ReadyVoiceState {
export function makeReadyVoiceState(overrides?: Partial<ReadyVoiceState>): 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>,
): ReadyRole {
export function makeReadyRole(overrides?: Partial<ReadyRole>): 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>,
): ReadyPayload {
export function makeReadyPayload(overrides?: Partial<ReadyPayload>): 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,
+2 -8
View File
@@ -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<T extends ServerMessage["type"]>(
type: T,
listener: WsListener<T>,
): () => void {
on<T extends ServerMessage["type"]>(type: T, listener: WsListener<T>): () => void {
if (!listeners.has(type)) {
listeners.set(type, new Set());
}
@@ -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();
},
@@ -57,7 +57,7 @@ const VOICE_INITIAL: VoiceState = {
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
listenOnly: false,
};
const UI_INITIAL: UiState = {
@@ -46,10 +46,7 @@ function createMockWsClient(): MockWsClient {
return crypto.randomUUID();
},
on<T extends ServerMessage["type"]>(
type: T,
listener: WsListener<T>,
): () => void {
on<T extends ServerMessage["type"]>(type: T, listener: WsListener<T>): () => 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);
@@ -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();
@@ -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");
@@ -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<string, string> } | 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<string, string>;
} | 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");
@@ -32,7 +32,11 @@ vi.stubGlobal("indexedDB", {
onerror: null,
objectStore: () => ({
get: () => {
const req: Record<string, unknown> = { onsuccess: null, onerror: null, result: undefined };
const req: Record<string, unknown> = {
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<typeof imageResponse>) => 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<typeof imageResponse>) => void) | undefined;
let resolveSecond: ((value: ReturnType<typeof imageResponse>) => 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<typeof imageResponse>) => 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);
});
});
});
});
@@ -37,10 +37,16 @@ vi.stubGlobal("indexedDB", {
close: vi.fn(),
transaction: () => {
const tx: Record<string, unknown> = {
oncomplete: null, onabort: null, onerror: null,
oncomplete: null,
onabort: null,
onerror: null,
objectStore: () => ({
get: () => {
const req: Record<string, unknown> = { onsuccess: null, onerror: null, result: undefined };
const req: Record<string, unknown> = {
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<string, unknown> = {
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", () => {
@@ -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);
});
});
@@ -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();
@@ -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", () => {
@@ -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> = {}): 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<typeof vi.fn>).mockReturnValue("confirmed");
(opts.pendingDeleteManager.tryDelete as ReturnType<typeof vi.fn>).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<typeof vi.fn> }).uploadFile.mockRejectedValue(new Error("upload failed"));
(
opts.api as unknown as { uploadFile: ReturnType<typeof vi.fn> }
).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<typeof vi.fn> }).pinMessage = vi.fn().mockResolvedValue(undefined);
(opts.api as unknown as { pinMessage: ReturnType<typeof vi.fn> }).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<typeof vi.fn> }).unpinMessage = vi.fn().mockResolvedValue(undefined);
(opts.api as unknown as { unpinMessage: ReturnType<typeof vi.fn> }).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<typeof vi.fn> }).pinMessage = vi.fn().mockRejectedValue(new Error("network error"));
(opts.api as unknown as { pinMessage: ReturnType<typeof vi.fn> }).pinMessage = vi
.fn()
.mockRejectedValue(new Error("network error"));
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
@@ -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,
@@ -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);
});
});
@@ -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> = {}): 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);
@@ -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";
+72 -46
View File
@@ -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,
@@ -38,9 +38,7 @@ function makeCallbacks(overrides: Partial<ConnectPageCallbacks> = {}): 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<void>((resolve) => { resolveLogin = resolve; });
const loginPromise = new Promise<void>((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<void>((resolve) => { resolveLogin = resolve; });
const loginPromise = new Promise<void>((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<void>((resolve) => { resolveLogin = resolve; });
const loginPromise = new Promise<void>((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<void>((resolve) => { resolveRegister = resolve; });
const registerPromise = new Promise<void>((resolve) => {
resolveRegister = resolve;
});
const onRegister = vi.fn().mockReturnValue(registerPromise);
const page = createConnectPage(makeCallbacks({ onRegister }), testProfiles);
@@ -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();
@@ -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();
});
});
@@ -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<any>(() => new Promise<void>((resolve) => { resolveCreate = resolve; }));
const onCreate = vi.fn<any>(
() =>
new Promise<void>((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
@@ -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<any>(() => new Promise<void>((resolve) => { resolveDelete = resolve; }));
const onConfirm = vi.fn<any>(
() =>
new Promise<void>((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();
@@ -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();
@@ -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<T extends ServerMessage["type"]>(
type: T,
listener: WsListener<T>,
): () => void {
on<T extends ServerMessage["type"]>(type: T, listener: WsListener<T>): () => 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<typeof vi.fn>).mock.calls;
const voiceLeaveSent = sendCalls.some(
([msg]: [{ type: string }]) => msg.type === "voice_leave",
(args: unknown[]) => (args[0] as Record<string, unknown>)?.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<typeof vi.fn>).mock.calls;
const voiceLeaveSent = sendCalls.some(
([msg]: [{ type: string }]) => msg.type === "voice_leave",
(args: unknown[]) => (args[0] as Record<string, unknown>)?.type === "voice_leave",
);
expect(voiceLeaveSent).toBe(false);
});
@@ -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);
@@ -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<any>(() => new Promise<void>((resolve) => { resolveSave = resolve; }));
const onSave = vi.fn<any>(
() =>
new Promise<void>((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);
+197 -52
View File
@@ -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("<html><head><title>Fresh</title></head></html>"));
fetchMock.mockResolvedValueOnce(
mockHtmlResponse("<html><head><title>Fresh</title></head></html>"),
);
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("<html><head><title>Recovered</title></head></html>"));
fetchMock.mockResolvedValueOnce(
mockHtmlResponse("<html><head><title>Recovered</title></head></html>"),
);
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("<html><head><title>F-Droid</title></head></html>"));
fetchMock.mockResolvedValue(
mockHtmlResponse("<html><head><title>F-Droid</title></head></html>"),
);
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("<html><head><title>OwnCord Local</title></head></html>"));
fetchMock.mockResolvedValue(
mockHtmlResponse("<html><head><title>OwnCord Local</title></head></html>"),
);
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('<html><head><meta property="og:title" content="Cached Title"></head></html>'),
mockHtmlResponse(
'<html><head><meta property="og:title" content="Cached Title"></head></html>',
),
);
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("<html><head><title>Public</title></head></html>"));
fetchMock.mockResolvedValueOnce(
mockHtmlResponse("<html><head><title>Public</title></head></html>"),
);
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("<html><head><title>IPv6</title></head></html>"));
fetchMock.mockResolvedValueOnce(
mockHtmlResponse("<html><head><title>IPv6</title></head></html>"),
);
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("<html><head><title>Fresh</title></head></html>"));
fetchMock.mockResolvedValueOnce(
mockHtmlResponse("<html><head><title>Fresh</title></head></html>"),
);
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("<html><head><title>Recovered</title></head></html>"));
fetchMock.mockResolvedValueOnce(
mockHtmlResponse("<html><head><title>Recovered</title></head></html>"),
);
const card2 = renderGenericLinkPreview("https://stale-error.example.com/fail");
document.body.appendChild(card2);
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
});
});
@@ -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");
@@ -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<any>(() => new Promise<void>((resolve) => {
resolveUpload = resolve;
}));
const onUpload = vi.fn<any>(
() =>
new Promise<void>((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<any>(() => new Promise<void>((resolve) => {
resolveUpload = resolve;
}));
const onUpload = vi.fn<any>(
() =>
new Promise<void>((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;
@@ -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();
});
+1 -1
View File
@@ -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);
}
});
@@ -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");
@@ -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 () => {
+4 -16
View File
@@ -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", "");
});
});
@@ -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<any>(),
mockClearLogBuffer: vi.fn<any>(),
@@ -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);
@@ -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<typeof oembedResponse>) => 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");
});
});
});
});
@@ -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();
+165 -54
View File
@@ -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<typeof oembedResponse>) => 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 }));
});
});
@@ -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);
@@ -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(),
@@ -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 () => {
@@ -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> = {}): 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?.();
});
@@ -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();
@@ -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);
@@ -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);
@@ -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,
});
@@ -122,7 +122,9 @@ function makeMockApi(overrides: Record<string, unknown> = {}) {
],
}),
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<unknown[]>;
onSearch: (
query: string,
chId: number | undefined,
signal?: AbortSignal,
) => Promise<unknown[]>;
};
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<unknown[]>;
onSearch: (
query: string,
chId: number | undefined,
signal?: AbortSignal,
) => Promise<unknown[]>;
};
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<unknown[]>;
onSearch: (
query: string,
chId: number | undefined,
signal?: AbortSignal,
) => Promise<unknown[]>;
};
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 });
@@ -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);
});
});
@@ -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", () => {
+31 -35
View File
@@ -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<Response>,
): FetchFn {
function createMockFetch(handler: (url: string, init?: RequestInit) => Promise<Response>): 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);
+12 -10
View File
@@ -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", () => {
@@ -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();
});
@@ -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 -----------------------------------------------------------
@@ -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<string, string>) => {
@@ -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);
@@ -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);
@@ -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");
});
});
// ---------------------------------------------------------------------------
@@ -4,20 +4,22 @@ const REGISTERED_NAME = "rnnoise-processor";
describe("rnnoise-worklet", () => {
let registerProcessorMock: ReturnType<typeof vi.fn>;
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<typeof vi.fn> } } | 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<typeof vi.fn> } } | null;
})
| null;
beforeEach(() => {
vi.resetModules();
@@ -78,4 +80,4 @@ describe("rnnoise-worklet", () => {
expect(processor._outReadPos).toBe(4);
expect(processor._outSampleOffset).toBe(0);
});
});
});
@@ -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<SearchResultItem[]>((resolve) => { resolveSearch = resolve; }),
const onSearch = vi.fn().mockImplementation(
() =>
new Promise<SearchResultItem[]>((resolve) => {
resolveSearch = resolve;
}),
);
const opts = makeOptions({ onSearch });
const overlay = createSearchOverlay(opts);
@@ -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;
@@ -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?.();
@@ -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);
+98 -86
View File
@@ -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<TestState>({ ...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<string, number>;
}
const store = createStore<MapState>({ items: new Map([['a', 1]]) });
const store = createStore<MapState>({ 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);
});
@@ -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();
});
});
});
@@ -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 {
+14 -34
View File
@@ -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<string, { url: string }> = {};
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"]);
@@ -40,8 +40,7 @@ describe("createTestHarness", () => {
it("queryAll() returns all matching elements within the container", () => {
harness = createTestHarness();
harness.container.innerHTML =
'<span class="item">a</span><span class="item">b</span>';
harness.container.innerHTML = '<span class="item">a</span><span class="item">b</span>';
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");
@@ -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("");
});
@@ -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", () => {
+1 -4
View File
@@ -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;

Some files were not shown because too many files have changed in this diff Show More