diff --git a/canvas/src/components/__tests__/AuditTrailPanel.format.test.ts b/canvas/src/components/__tests__/AuditTrailPanel.format.test.ts new file mode 100644 index 000000000..43a47fbec --- /dev/null +++ b/canvas/src/components/__tests__/AuditTrailPanel.format.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +/** + * Tests for formatAuditRelativeTime exported from AuditTrailPanel. + */ +import { describe, it, expect } from "vitest"; +import { formatAuditRelativeTime } from "../AuditTrailPanel"; + +describe("formatAuditRelativeTime", () => { + const now = new Date("2026-05-18T12:00:00Z").getTime(); + + it('returns "just now" for timestamps less than 60s ago', () => { + const ts = new Date(now - 30_000).toISOString(); // 30s ago + expect(formatAuditRelativeTime(ts, now)).toBe("just now"); + }); + + it("returns minutes for timestamps under 1h", () => { + const ts = new Date(now - 5 * 60_000).toISOString(); // 5m ago + expect(formatAuditRelativeTime(ts, now)).toBe("5m ago"); + }); + + it("returns hours for timestamps under 24h", () => { + const ts = new Date(now - 3 * 3_600_000).toISOString(); // 3h ago + expect(formatAuditRelativeTime(ts, now)).toBe("3h ago"); + }); + + it("returns locale date for timestamps older than 24h", () => { + const ts = new Date(now - 2 * 86_400_000).toISOString(); // 2d ago + const result = formatAuditRelativeTime(ts, now); + // Returns a locale date string; just verify it's a non-empty string + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + expect(result).not.toBe("just now"); + expect(result).not.toMatch(/m ago$/); + expect(result).not.toMatch(/h ago$/); + }); + + it("handles exactly 60s boundary as minutes", () => { + const ts = new Date(now - 60_000).toISOString(); // exactly 1m ago + expect(formatAuditRelativeTime(ts, now)).toBe("1m ago"); + }); + + it("handles exactly 3600s boundary as hours", () => { + const ts = new Date(now - 3_600_000).toISOString(); // exactly 1h ago + expect(formatAuditRelativeTime(ts, now)).toBe("1h ago"); + }); + + it("handles exactly 86400s boundary", () => { + const ts = new Date(now - 86_400_000).toISOString(); // exactly 24h ago + const result = formatAuditRelativeTime(ts, now); + // Exactly 24h should fall into the "days" branch + expect(typeof result).toBe("string"); + expect(result).not.toMatch(/m ago$/); + expect(result).not.toMatch(/h ago$/); + }); +}); diff --git a/canvas/src/components/__tests__/MemoryInspectorPanel.helpers.test.ts b/canvas/src/components/__tests__/MemoryInspectorPanel.helpers.test.ts new file mode 100644 index 000000000..c2b2d9350 --- /dev/null +++ b/canvas/src/components/__tests__/MemoryInspectorPanel.helpers.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +/** + * Tests for exported helpers from MemoryInspectorPanel: + * isPluginUnavailableError, formatTTL. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel"; + +describe("isPluginUnavailableError", () => { + it("returns true when error message contains MEMORY_PLUGIN_URL", () => { + const err = new Error("MEMORY_PLUGIN_URL is not configured"); + expect(isPluginUnavailableError(err)).toBe(true); + }); + + it("returns false when error message does not contain MEMORY_PLUGIN_URL", () => { + const err = new Error("Connection refused"); + expect(isPluginUnavailableError(err)).toBe(false); + }); + + it("returns false for non-Error values", () => { + expect(isPluginUnavailableError("string error")).toBe(false); + expect(isPluginUnavailableError(null)).toBe(false); + expect(isPluginUnavailableError(undefined)).toBe(false); + expect(isPluginUnavailableError({})).toBe(false); + }); + + it("handles Error with empty message", () => { + expect(isPluginUnavailableError(new Error(""))).toBe(false); + }); +}); + +describe("formatTTL", () => { + // Freeze time at 2026-05-18T12:00:00Z for deterministic tests. + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-18T12:00:00Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns empty string for null", () => { + expect(formatTTL(null)).toBe(""); + }); + + it("returns empty string for undefined", () => { + expect(formatTTL(undefined)).toBe(""); + }); + + it("returns empty string for empty string", () => { + expect(formatTTL("")).toBe(""); + }); + + it("returns 'expired' for past timestamps", () => { + const past = new Date(Date.now() - 60_000).toISOString(); + expect(formatTTL(past)).toBe("expired"); + }); + + it("returns seconds for sub-minute future TTLs", () => { + const future = new Date(Date.now() + 30_000).toISOString(); + expect(formatTTL(future)).toBe("30s"); + }); + + it("returns minutes for sub-hour future TTLs", () => { + const future = new Date(Date.now() + 5 * 60_000).toISOString(); + expect(formatTTL(future)).toBe("5m"); + }); + + it("returns hours for sub-day future TTLs", () => { + const future = new Date(Date.now() + 3 * 3_600_000).toISOString(); + expect(formatTTL(future)).toBe("3h"); + }); + + it("returns days for TTLs longer than 24h", () => { + const future = new Date(Date.now() + 2 * 86_400_000).toISOString(); + expect(formatTTL(future)).toBe("2d"); + }); + + it("returns empty string for invalid date string", () => { + expect(formatTTL("not-a-date")).toBe(""); + }); +}); diff --git a/canvas/src/hooks/__tests__/useKeyboardShortcut.test.tsx b/canvas/src/hooks/__tests__/useKeyboardShortcut.test.tsx new file mode 100644 index 000000000..dc8ebccc5 --- /dev/null +++ b/canvas/src/hooks/__tests__/useKeyboardShortcut.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +/** + * Tests for useKeyboardShortcut. + * + * Strategy: use renderHook from @testing-library/react so useEffect fires + * before dispatch. We spy on window.addEventListener to capture the registered + * handler. Events are dispatched by calling the captured handler directly + * with a KeyboardEvent that has metaKey/ctrlKey overridden via + * Object.defineProperty (jsdom's built-in modifier-key event is unreliable). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { cleanup, act, renderHook } from "@testing-library/react"; +import { useState, useCallback } from "react"; +import { useKeyboardShortcut } from "../use-keyboard-shortcut"; + +afterEach(cleanup); + +// Capture the most-recently registered keydown handler so tests can dispatch through it. +let registeredHandler: ((e: KeyboardEvent) => void) | null = null; + +const addSpy = vi.spyOn(window, "addEventListener").mockImplementation( + (event: string, handler: EventListener) => { + if (event === "keydown") { + registeredHandler = handler as (e: KeyboardEvent) => void; + } + }, +); + +const removeSpy = vi.spyOn(window, "removeEventListener").mockImplementation( + (event: string) => { + if (event === "keydown") { + registeredHandler = null; + } + }, +); + +beforeEach(() => { + registeredHandler = null; + addSpy.mockClear(); + removeSpy.mockClear(); +}); + +/** + * Dispatch a keydown event through the captured handler. + * Wrapped in act() so React flushes any state updates synchronously. + * Bypasses jsdom's internal event routing (which doesn't go through + * window.EventTarget.prototype.addEventListener for fireEvent dispatch). + */ +function dispatchKeydown( + key: string, + { meta = false, ctrl = false }: { meta?: boolean; ctrl?: boolean } = {}, +) { + act(() => { + const e = new KeyboardEvent("keydown", { key, bubbles: true }); + Object.defineProperty(e, "metaKey", { value: meta }); + Object.defineProperty(e, "ctrlKey", { value: ctrl }); + registeredHandler?.(e); + }); +} + +describe("useKeyboardShortcut", () => { + describe("enabled=false", () => { + it("does not register a keydown listener", () => { + renderHook(() => + useKeyboardShortcut("k", vi.fn(), { enabled: false }), + ); + expect(addSpy).not.toHaveBeenCalledWith("keydown", expect.any(Function)); + }); + }); + + describe("meta modifier", () => { + it("fires callback on Cmd+K", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, { meta: true })); + dispatchKeydown("k", { meta: true }); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("does NOT fire on Ctrl+K when only meta=true", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, { meta: true })); + dispatchKeydown("k", { ctrl: true }); + expect(cb).not.toHaveBeenCalled(); + }); + + it("does NOT fire on plain K even with meta=true", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, { meta: true })); + dispatchKeydown("k", { meta: false, ctrl: false }); + expect(cb).not.toHaveBeenCalled(); + }); + }); + + describe("ctrl modifier", () => { + it("fires callback on Ctrl+K", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, { ctrl: true })); + dispatchKeydown("k", { ctrl: true }); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("does NOT fire on Cmd+K when only ctrl=true", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, { ctrl: true })); + dispatchKeydown("k", { meta: true }); + expect(cb).not.toHaveBeenCalled(); + }); + }); + + describe("no-modifier guard", () => { + it("does not fire when no modifier is held", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, {})); + dispatchKeydown("k", { meta: false, ctrl: false }); + expect(cb).not.toHaveBeenCalled(); + }); + }); + + describe("key mismatch", () => { + it("does not fire when wrong key is pressed", () => { + const cb = vi.fn(); + renderHook(() => useKeyboardShortcut("k", cb, { meta: true })); + dispatchKeydown("j", { meta: true }); + expect(cb).not.toHaveBeenCalled(); + }); + }); + + describe("count reflects shortcut fires", () => { + it("increments when Cmd+K fires", () => { + const { result } = renderHook(() => { + const [count, setCount] = useState(0); + const cb = useCallback(() => setCount((c) => c + 1), []); + useKeyboardShortcut("k", cb, { meta: true }); + return count; + }); + expect(result.current).toBe(0); + dispatchKeydown("k", { meta: true }); + expect(result.current).toBe(1); + dispatchKeydown("k", { meta: true }); + expect(result.current).toBe(2); + }); + + it("does not increment on wrong modifier", () => { + const { result } = renderHook(() => { + const [count, setCount] = useState(0); + const cb = useCallback(() => setCount((c) => c + 1), []); + useKeyboardShortcut("k", cb, { meta: true }); + return count; + }); + dispatchKeydown("k", { ctrl: true }); // wrong modifier + expect(result.current).toBe(0); + }); + }); + + describe("cleanup on unmount", () => { + it("removes the keydown listener on unmount", () => { + const cb = vi.fn(); + const { unmount } = renderHook(() => + useKeyboardShortcut("k", cb, { meta: true }), + ); + expect(removeSpy).not.toHaveBeenCalled(); + unmount(); + expect(removeSpy).toHaveBeenCalledWith("keydown", expect.any(Function)); + }); + }); +}); diff --git a/canvas/src/hooks/__tests__/useSocketEvent.test.tsx b/canvas/src/hooks/__tests__/useSocketEvent.test.tsx new file mode 100644 index 000000000..bcf7213ad --- /dev/null +++ b/canvas/src/hooks/__tests__/useSocketEvent.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +/** + * Tests for useSocketEvent. + * + * Covers: + * - subscribeSocketEvents is called on mount + * - Unsubscribe is called on unmount + * - subscribeSocketEvents is called only once (ref-based, not render-based) + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import React from "react"; +import { useSocketEvent } from "../useSocketEvent"; + +afterEach(cleanup); + +// Mutable ref shared between vi.mock factory and test helpers +const state = { + handler: null as ((msg: unknown) => void) | null, + unsubscribe: null as (() => void) | null, +}; + +// Module-level mock — factory uses the state object so beforeEach can update it +vi.mock("@/store/socket-events", () => ({ + subscribeSocketEvents: vi.fn().mockImplementation(() => { + if (state.unsubscribe) return state.unsubscribe; + const fn = vi.fn(); + state.unsubscribe = fn; + return fn; + }), +})); + +import { subscribeSocketEvents } from "@/store/socket-events"; + +beforeEach(() => { + state.handler = null; + state.unsubscribe = null; + vi.mocked(subscribeSocketEvents).mockImplementation(() => { + const fn = vi.fn(); + state.unsubscribe = fn; + return fn; + }); +}); + +// Dispatch a message through the subscribed handler +function dispatchMsg(msg: unknown) { + if (state.handler) { + state.handler(msg); + } +} + +// Consumer component that stores the handler ref +function SocketConsumer({ cb }: { cb: (msg: unknown) => void }) { + useSocketEvent(cb as (msg: unknown) => void); + // Store the handler so tests can dispatch through it + // We do this by re-mocking to capture the handler + return
; +} + +describe("useSocketEvent", () => { + it("calls subscribeSocketEvents on mount", () => { + render(