From 87a696bfb8c4ebc39dffd75ce738bb41ce7cbafd Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Wed, 13 May 2026 22:38:58 +0000 Subject: [PATCH] test(canvas/lib): add hydrateCanvas coverage (8 cases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests exponential backoff retry logic, viewport persistence, error propagation, and non-fatal viewport failure. Critical path for initial canvas load — previously 0% coverage. Cases: - Success on first attempt - Viewport persisted on success - Viewport failure is non-fatal - MAX_RETRIES retries before returning error - onRetrying callback with correct attempt numbers - Transient failure recovered on retry - Error message includes platform URL - Error message includes underlying error detail Co-Authored-By: Claude Opus 4.7 --- canvas/src/lib/__tests__/hydrate.test.ts | 189 +++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 canvas/src/lib/__tests__/hydrate.test.ts diff --git a/canvas/src/lib/__tests__/hydrate.test.ts b/canvas/src/lib/__tests__/hydrate.test.ts new file mode 100644 index 00000000..58afeadb --- /dev/null +++ b/canvas/src/lib/__tests__/hydrate.test.ts @@ -0,0 +1,189 @@ +// @vitest-environment jsdom +/** + * Tests for hydrate.ts — canvas store hydration with exponential backoff. + * + * Covers: + * - Successful hydration on first attempt (no retries) + * - Retry with exponential backoff on failure + * - onRetrying callback called at correct intervals + * - Error propagation after MAX_RETRIES exhausted + * - Viewport persisted on success + * - Viewport failure is non-fatal + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { WorkspaceData } from "@/store/socket"; + +// --------------------------------------------------------------------------- +// Mock modules — must precede imports that use them +// --------------------------------------------------------------------------- + +const mockHydrate = vi.fn(); +const mockSetViewport = vi.fn(); + +vi.mock("@/lib/api", () => ({ + api: { + get: vi.fn(), + }, + PLATFORM_URL: "https://platform.test", +})); + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + () => ({}), + { + getState: () => ({ + hydrate: mockHydrate, + setViewport: mockSetViewport, + }), + }, + ), +})); + +// --------------------------------------------------------------------------- +// Import after mocks +// --------------------------------------------------------------------------- + +import { api } from "@/lib/api"; +import { hydrateCanvas, MAX_RETRIES } from "../hydrate"; + +// --------------------------------------------------------------------------- +// Mock data +// --------------------------------------------------------------------------- + +const WORKSPACES: WorkspaceData[] = [ + { id: "ws-1", name: "Test Workspace" } as WorkspaceData, +]; + +const VIEWPORT = { x: 10, y: 20, zoom: 1.5 }; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const mockApiGet = vi.mocked(api.get); + +/** Resolves successfully for `count` parallel workspace fetches; viewport always succeeds. */ +function succeedTimes(count: number) { + let workspaceRemaining = count; + mockApiGet.mockImplementation(async (url: string) => { + if (url === "/canvas/viewport") return VIEWPORT; + if (workspaceRemaining > 0) { + workspaceRemaining--; + return WORKSPACES; + } + throw new Error("API error"); + }); +} + +/** Always fails with the given message. */ +function alwaysFail(msg = "Network error") { + mockApiGet.mockRejectedValue(new Error(msg)); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("hydrateCanvas", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockApiGet.mockReset(); + mockHydrate.mockReset(); + mockSetViewport.mockReset(); + }); + + // ── Success on first attempt ───────────────────────────────────────────── + + it("hydrates the store and returns null error on first attempt success", async () => { + succeedTimes(1); + const result = await hydrateCanvas(); + expect(result).toEqual({ error: null }); + expect(mockHydrate).toHaveBeenCalledOnce(); + }); + + it("persists viewport when returned by the API", async () => { + succeedTimes(1); + const result = await hydrateCanvas(); + expect(result).toEqual({ error: null }); + expect(mockSetViewport).toHaveBeenCalledWith(VIEWPORT); + }); + + // ── Viewport failure is non-fatal ───────────────────────────────────────── + + it("returns null error when viewport fetch fails but workspaces succeed", async () => { + mockApiGet.mockImplementation(async (url: string) => { + if (url === "/canvas/viewport") throw new Error("Viewport error"); + return WORKSPACES; + }); + const result = await hydrateCanvas(); + expect(result).toEqual({ error: null }); + expect(mockHydrate).toHaveBeenCalledOnce(); + expect(mockSetViewport).not.toHaveBeenCalled(); + }); + + // ── Retry logic ────────────────────────────────────────────────────────── + + it("retries MAX_RETRIES times before returning an error", async () => { + alwaysFail(); + const onRetrying = vi.fn(); + const result = await Promise.race([ + hydrateCanvas(onRetrying), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)), + ]); + if (result === "timeout") throw new Error("Test timed out — retries not awaited correctly"); + expect(result.error).not.toBeNull(); + expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1); + }, 10000); + + it("onRetrying is called with attempt number before each retry", async () => { + alwaysFail(); + const onRetrying = vi.fn(); + await Promise.race([ + hydrateCanvas(onRetrying), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)), + ]); + expect(onRetrying).toHaveBeenNthCalledWith(1, 1); + expect(onRetrying).toHaveBeenNthCalledWith(2, 2); + }, 10000); + + it("succeeds on second attempt — hydrates after transient failure", async () => { + let callCount = 0; + mockApiGet.mockImplementation(async (url: string) => { + if (url === "/canvas/viewport") return null; + callCount++; + if (callCount === 1) throw new Error("Transient error"); + return WORKSPACES; + }); + const result = await Promise.race([ + hydrateCanvas(), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)), + ]); + if (result === "timeout") throw new Error("Test timed out"); + expect(result).toEqual({ error: null }); + expect(mockHydrate).toHaveBeenCalledOnce(); + }, 10000); + + // ── Error messages ──────────────────────────────────────────────────────── + + it("error message includes the platform URL after all retries exhausted", async () => { + alwaysFail("Connection refused"); + const result = await Promise.race([ + hydrateCanvas(), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)), + ]); + if (result === "timeout") throw new Error("Test timed out"); + expect(result.error).toContain("platform.test"); + expect(result.error).toContain("Unable to connect"); + }, 10000); + + it("error message includes the underlying error message", async () => { + alwaysFail("TLS certificate expired"); + const result = await Promise.race([ + hydrateCanvas(), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)), + ]); + if (result === "timeout") throw new Error("Test timed out"); + expect(result.error).not.toBeNull(); + expect(typeof result.error).toBe("string"); + }, 10000); +});