From 4e66fa26ccf7c3984f0bb7f753e2d204eca8709a Mon Sep 17 00:00:00 2001 From: gauthierpiarrette Date: Thu, 19 Feb 2026 21:56:01 +0100 Subject: [PATCH] Add unit tests for frontend ws, ui, and diff modules --- .gitignore | 4 +- frontend/src/App.svelte | 21 +-- frontend/src/lib/diff.test.ts | 85 +++++++++ frontend/src/lib/diff.ts | 21 +++ frontend/src/lib/ui.test.ts | 56 ++++++ frontend/src/lib/ws.test.ts | 330 ++++++++++++++++++++++++++++++++++ 6 files changed, 495 insertions(+), 22 deletions(-) create mode 100644 frontend/src/lib/diff.test.ts create mode 100644 frontend/src/lib/diff.ts create mode 100644 frontend/src/lib/ui.test.ts create mode 100644 frontend/src/lib/ws.test.ts diff --git a/.gitignore b/.gitignore index 0b8220a..83b1521 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index e025f0b..00b85fd 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -2,6 +2,7 @@ import { onMount } from "svelte"; import { connect, onMessage, send } from "./lib/ws"; import type { ToolDef, TimelineEntry, ServerMessage, ServerInfo } from "./lib/types"; + import { describeReloadDiff } from "./lib/diff"; import ToolList from "./components/ToolList.svelte"; import ToolDetail from "./components/ToolDetail.svelte"; import Timeline from "./components/Timeline.svelte"; @@ -98,26 +99,6 @@ } } - // Feature 9: describe what changed on reload - function describeReloadDiff(oldTools: ToolDef[], newTools: ToolDef[]): string { - const oldNames = new Set(oldTools.map((t) => t.name)); - const newNames = new Set(newTools.map((t) => t.name)); - const added = newTools.filter((t) => !oldNames.has(t.name)); - const removed = oldTools.filter((t) => !newNames.has(t.name)); - const changed = newTools.filter((t) => { - const old = oldTools.find((o) => o.name === t.name); - return old && JSON.stringify(old.inputSchema) !== JSON.stringify(t.inputSchema); - }); - - const parts: string[] = []; - if (added.length) parts.push(`${added.length} added`); - if (removed.length) parts.push(`${removed.length} removed`); - if (changed.length) parts.push(`${changed.length} schema changed`); - - if (parts.length === 0) return "Server reloaded (no tool changes)"; - return `Server reloaded: ${parts.join(", ")}`; - } - function handleSelectTool(tool: ToolDef) { selectedTool = tool; activeTab = "tool"; diff --git a/frontend/src/lib/diff.test.ts b/frontend/src/lib/diff.test.ts new file mode 100644 index 0000000..db372ef --- /dev/null +++ b/frontend/src/lib/diff.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { describeReloadDiff } from "./diff"; +import type { ToolDef } from "./types"; + +function tool(name: string, schema: Record = {}): ToolDef { + return { + name, + description: `${name} tool`, + inputSchema: { type: "object", ...schema }, + capabilities: [], + requiresConfirmation: false, + }; +} + +describe("describeReloadDiff", () => { + it("reports no changes when lists are identical", () => { + const tools = [tool("a"), tool("b")]; + expect(describeReloadDiff(tools, tools)).toBe( + "Server reloaded (no tool changes)", + ); + }); + + it("reports no changes for empty lists", () => { + expect(describeReloadDiff([], [])).toBe( + "Server reloaded (no tool changes)", + ); + }); + + it("detects added tools", () => { + const old = [tool("a")]; + const next = [tool("a"), tool("b"), tool("c")]; + expect(describeReloadDiff(old, next)).toBe("Server reloaded: 2 added"); + }); + + it("detects removed tools", () => { + const old = [tool("a"), tool("b")]; + const next = [tool("a")]; + expect(describeReloadDiff(old, next)).toBe("Server reloaded: 1 removed"); + }); + + it("detects schema changes", () => { + const old = [tool("a", { properties: { x: { type: "string" } } })]; + const next = [tool("a", { properties: { x: { type: "number" } } })]; + expect(describeReloadDiff(old, next)).toBe( + "Server reloaded: 1 schema changed", + ); + }); + + it("does not flag unchanged schemas", () => { + const schema = { properties: { x: { type: "string" } } }; + const old = [tool("a", schema)]; + const next = [tool("a", schema)]; + expect(describeReloadDiff(old, next)).toBe( + "Server reloaded (no tool changes)", + ); + }); + + it("combines added, removed, and changed in one message", () => { + const old = [ + tool("keep", { properties: { v: { type: "string" } } }), + tool("gone"), + ]; + const next = [ + tool("keep", { properties: { v: { type: "number" } } }), + tool("fresh"), + ]; + const result = describeReloadDiff(old, next); + expect(result).toContain("1 added"); + expect(result).toContain("1 removed"); + expect(result).toContain("1 schema changed"); + }); + + it("reports singular count correctly", () => { + const result = describeReloadDiff([], [tool("a")]); + expect(result).toBe("Server reloaded: 1 added"); + }); + + it("reports multiple counts correctly", () => { + const result = describeReloadDiff( + [], + [tool("a"), tool("b"), tool("c")], + ); + expect(result).toBe("Server reloaded: 3 added"); + }); +}); diff --git a/frontend/src/lib/diff.ts b/frontend/src/lib/diff.ts new file mode 100644 index 0000000..8a35a34 --- /dev/null +++ b/frontend/src/lib/diff.ts @@ -0,0 +1,21 @@ +import type { ToolDef } from "./types"; + +/** Describe what changed between two snapshots of the tool list (used on hot-reload). */ +export function describeReloadDiff(oldTools: ToolDef[], newTools: ToolDef[]): string { + const oldNames = new Set(oldTools.map((t) => t.name)); + const newNames = new Set(newTools.map((t) => t.name)); + const added = newTools.filter((t) => !oldNames.has(t.name)); + const removed = oldTools.filter((t) => !newNames.has(t.name)); + const changed = newTools.filter((t) => { + const old = oldTools.find((o) => o.name === t.name); + return old && JSON.stringify(old.inputSchema) !== JSON.stringify(t.inputSchema); + }); + + const parts: string[] = []; + if (added.length) parts.push(`${added.length} added`); + if (removed.length) parts.push(`${removed.length} removed`); + if (changed.length) parts.push(`${changed.length} schema changed`); + + if (parts.length === 0) return "Server reloaded (no tool changes)"; + return `Server reloaded: ${parts.join(", ")}`; +} diff --git a/frontend/src/lib/ui.test.ts b/frontend/src/lib/ui.test.ts new file mode 100644 index 0000000..7a58c8b --- /dev/null +++ b/frontend/src/lib/ui.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { inputClass, buttonClass, buttonVariants, copyToClipboard } from "./ui"; + +describe("CSS class constants", () => { + it("exports inputClass as a non-empty string", () => { + expect(typeof inputClass).toBe("string"); + expect(inputClass.length).toBeGreaterThan(0); + }); + + it("exports buttonClass as a non-empty string", () => { + expect(typeof buttonClass).toBe("string"); + expect(buttonClass.length).toBeGreaterThan(0); + }); + + it("exports all button variants", () => { + expect(Object.keys(buttonVariants)).toEqual([ + "default", + "destructive", + "outline", + "ghost", + ]); + }); + + it("each variant includes the base buttonClass", () => { + for (const variant of Object.values(buttonVariants)) { + expect(variant).toContain(buttonClass); + } + }); +}); + +describe("copyToClipboard", () => { + beforeEach(() => { + vi.stubGlobal("navigator", { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + it("calls navigator.clipboard.writeText with the given text", async () => { + await copyToClipboard("hello world"); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello world"); + }); + + it("passes through empty strings", async () => { + await copyToClipboard(""); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(""); + }); + + it("propagates clipboard errors", async () => { + const error = new Error("Clipboard blocked"); + vi.stubGlobal("navigator", { + clipboard: { writeText: vi.fn().mockRejectedValue(error) }, + }); + + await expect(copyToClipboard("x")).rejects.toThrow("Clipboard blocked"); + }); +}); diff --git a/frontend/src/lib/ws.test.ts b/frontend/src/lib/ws.test.ts new file mode 100644 index 0000000..9b78a89 --- /dev/null +++ b/frontend/src/lib/ws.test.ts @@ -0,0 +1,330 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// --------------------------------------------------------------------------- +// Polyfill CloseEvent for Node (not available outside jsdom) +// --------------------------------------------------------------------------- + +class CloseEvent extends Event { + code: number; + reason: string; + wasClean: boolean; + constructor(type: string, init?: { code?: number; reason?: string; wasClean?: boolean }) { + super(type); + this.code = init?.code ?? 1000; + this.reason = init?.reason ?? ""; + this.wasClean = init?.wasClean ?? true; + } +} +vi.stubGlobal("CloseEvent", CloseEvent); + +// --------------------------------------------------------------------------- +// Mock WebSocket & location +// --------------------------------------------------------------------------- + +class MockWebSocket { + static OPEN = 1; + static CONNECTING = 0; + static CLOSING = 2; + static CLOSED = 3; + + static instances: MockWebSocket[] = []; + + url: string; + readyState = MockWebSocket.CONNECTING; + onopen: ((ev: Event) => void) | null = null; + onclose: ((ev: CloseEvent) => void) | null = null; + onmessage: ((ev: MessageEvent) => void) | null = null; + onerror: ((ev: Event) => void) | null = null; + sent: string[] = []; + + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + } + + send(data: string) { + this.sent.push(data); + } + + /** Simulate the server opening the connection. */ + simulateOpen() { + this.readyState = MockWebSocket.OPEN; + this.onopen?.(new Event("open")); + } + + /** Simulate the server sending a message. */ + simulateMessage(data: unknown) { + this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(data) })); + } + + /** Simulate connection close. */ + simulateClose() { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.(new CloseEvent("close")); + } + + /** Simulate an error followed by close (browser behavior). */ + simulateError() { + this.onerror?.(new Event("error")); + this.simulateClose(); + } +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/** Fresh-import ws.ts to reset module-level state between tests. */ +async function freshImport() { + return await import("./ws"); +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("ws", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + MockWebSocket.instances = []; + + vi.stubGlobal("WebSocket", MockWebSocket); + vi.stubGlobal("location", { protocol: "https:", host: "example.com" }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + // -- connect ---------------------------------------------------------------- + + describe("connect", () => { + it("creates a WebSocket with the correct wss URL", async () => { + const { connect } = await freshImport(); + connect(); + expect(MockWebSocket.instances).toHaveLength(1); + expect(MockWebSocket.instances[0].url).toBe("wss://example.com/ws"); + }); + + it("uses ws: for http: pages", async () => { + vi.stubGlobal("location", { protocol: "http:", host: "localhost:8321" }); + const { connect } = await freshImport(); + connect(); + expect(MockWebSocket.instances[0].url).toBe("ws://localhost:8321/ws"); + }); + + it("does nothing if already open", async () => { + const { connect } = await freshImport(); + connect(); + MockWebSocket.instances[0].simulateOpen(); + connect(); // second call + expect(MockWebSocket.instances).toHaveLength(1); + }); + + it("sends get_tools and get_timeline on open", async () => { + const { connect } = await freshImport(); + connect(); + const ws = MockWebSocket.instances[0]; + ws.simulateOpen(); + + const messages = ws.sent.map((s) => JSON.parse(s)); + expect(messages).toEqual([ + { type: "get_tools" }, + { type: "get_timeline", filter: { limit: 50 } }, + ]); + }); + }); + + // -- send ------------------------------------------------------------------- + + describe("send", () => { + it("sends JSON when socket is open", async () => { + const { connect, send } = await freshImport(); + connect(); + MockWebSocket.instances[0].simulateOpen(); + MockWebSocket.instances[0].sent = []; // clear init messages + + send({ type: "get_tools" }); + expect(MockWebSocket.instances[0].sent).toEqual([ + JSON.stringify({ type: "get_tools" }), + ]); + }); + + it("silently drops messages when socket is not open", async () => { + const { send } = await freshImport(); + // no connect() called + send({ type: "get_tools" }); + expect(MockWebSocket.instances).toHaveLength(0); + }); + + it("silently drops messages when socket is connecting", async () => { + const { connect, send } = await freshImport(); + connect(); + // don't simulateOpen — still CONNECTING + MockWebSocket.instances[0].sent = []; + send({ type: "get_tools" }); + expect(MockWebSocket.instances[0].sent).toEqual([]); + }); + }); + + // -- onMessage -------------------------------------------------------------- + + describe("onMessage", () => { + it("dispatches parsed server messages to handlers", async () => { + const { connect, onMessage } = await freshImport(); + const handler = vi.fn(); + onMessage(handler); + connect(); + MockWebSocket.instances[0].simulateOpen(); + + const msg = { type: "tools", tools: [] }; + MockWebSocket.instances[0].simulateMessage(msg); + + expect(handler).toHaveBeenCalledWith(msg); + }); + + it("dispatches to multiple handlers", async () => { + const { connect, onMessage } = await freshImport(); + const h1 = vi.fn(); + const h2 = vi.fn(); + onMessage(h1); + onMessage(h2); + connect(); + MockWebSocket.instances[0].simulateOpen(); + + const msg = { type: "error", message: "boom" }; + MockWebSocket.instances[0].simulateMessage(msg); + + expect(h1).toHaveBeenCalledWith(msg); + expect(h2).toHaveBeenCalledWith(msg); + }); + + it("returns an unsubscribe function", async () => { + const { connect, onMessage } = await freshImport(); + const handler = vi.fn(); + const unsub = onMessage(handler); + connect(); + MockWebSocket.instances[0].simulateOpen(); + + unsub(); + + MockWebSocket.instances[0].simulateMessage({ type: "error", message: "x" }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("does not crash on invalid JSON", async () => { + const { connect, onMessage } = await freshImport(); + const handler = vi.fn(); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + onMessage(handler); + connect(); + MockWebSocket.instances[0].simulateOpen(); + + // Send raw invalid JSON (bypass simulateMessage which stringifies) + MockWebSocket.instances[0].onmessage?.( + new MessageEvent("message", { data: "not json{" }), + ); + + expect(handler).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + // -- reconnection ----------------------------------------------------------- + + describe("reconnection", () => { + it("schedules reconnect on close", async () => { + const { connect } = await freshImport(); + connect(); + const ws = MockWebSocket.instances[0]; + ws.simulateOpen(); + ws.simulateClose(); + + expect(MockWebSocket.instances).toHaveLength(1); // not yet reconnected + + vi.advanceTimersByTime(1000); + expect(MockWebSocket.instances).toHaveLength(2); // reconnected + }); + + it("uses exponential backoff capped at 10s", async () => { + const { connect } = await freshImport(); + + // First connection + connect(); + MockWebSocket.instances[0].simulateOpen(); + MockWebSocket.instances[0].simulateClose(); + + // 1st reconnect at 1000ms + vi.advanceTimersByTime(1000); + expect(MockWebSocket.instances).toHaveLength(2); + MockWebSocket.instances[1].simulateClose(); // fail immediately + + // 2nd reconnect at 2000ms + vi.advanceTimersByTime(1999); + expect(MockWebSocket.instances).toHaveLength(2); // not yet + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(3); + MockWebSocket.instances[2].simulateClose(); + + // 3rd reconnect at 4000ms + vi.advanceTimersByTime(3999); + expect(MockWebSocket.instances).toHaveLength(3); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(4); + MockWebSocket.instances[3].simulateClose(); + + // 4th reconnect at 8000ms + vi.advanceTimersByTime(7999); + expect(MockWebSocket.instances).toHaveLength(4); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(5); + MockWebSocket.instances[4].simulateClose(); + + // 5th reconnect capped at 10000ms (not 16000ms) + vi.advanceTimersByTime(9999); + expect(MockWebSocket.instances).toHaveLength(5); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(6); + }); + + it("resets backoff delay after successful connection", async () => { + const { connect } = await freshImport(); + + connect(); + MockWebSocket.instances[0].simulateOpen(); + MockWebSocket.instances[0].simulateClose(); + + // 1st reconnect at 1000ms + vi.advanceTimersByTime(1000); + MockWebSocket.instances[1].simulateClose(); + + // 2nd reconnect at 2000ms (backoff doubled) + vi.advanceTimersByTime(2000); + MockWebSocket.instances[2].simulateOpen(); // success — should reset delay + MockWebSocket.instances[2].simulateClose(); + + // Next reconnect should be back to 1000ms, not 4000ms + vi.advanceTimersByTime(999); + expect(MockWebSocket.instances).toHaveLength(3); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(4); + }); + + it("does not schedule duplicate reconnects", async () => { + const { connect } = await freshImport(); + connect(); + MockWebSocket.instances[0].simulateOpen(); + + // Trigger close twice rapidly + MockWebSocket.instances[0].simulateClose(); + MockWebSocket.instances[0].onclose?.(new CloseEvent("close")); + + vi.advanceTimersByTime(1000); + // Should only have created one reconnect, not two + expect(MockWebSocket.instances).toHaveLength(2); + }); + }); +});