mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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("/");
|
||||
});
|
||||
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user