// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type PropsWithChildren } from "react";
import { InMemoryRouterProvider } from "../../router/in-memory-router.js";
import { useRouter } from "../../router/context.js";
import type { PageInfo } from "../../viewer/types.js";
import type { CoID, RawCoValue } from "cojson";
const STORAGE_KEY = "jazz-inspector-paths";
function Wrapper({ children }: PropsWithChildren) {
return {children};
}
describe("InMemoryRouterProvider", () => {
beforeEach(() => {
if (typeof localStorage !== "undefined") {
localStorage.clear();
}
vi.clearAllMocks();
});
afterEach(() => {
if (typeof localStorage !== "undefined") {
localStorage.clear();
}
});
describe("initialization", () => {
it("should initialize with empty path when no localStorage and no defaultPath", () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
expect(result.current.path).toEqual([]);
});
it("should initialize with defaultPath when provided", () => {
const defaultPath: PageInfo[] = [
{ coId: "co_test1" as CoID, name: "Test1" },
{ coId: "co_test2" as CoID, name: "Test2" },
];
function WrapperWithDefaultPath({ children }: PropsWithChildren) {
return (
{children}
);
}
const { result } = renderHook(() => useRouter(), {
wrapper: WrapperWithDefaultPath,
});
expect(result.current.path).toEqual(defaultPath);
});
it("should initialize from localStorage when available", () => {
const storedPath: PageInfo[] = [
{ coId: "co_stored1" as CoID, name: "Stored1" },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(storedPath));
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
expect(result.current.path).toEqual(storedPath);
});
it("should sync defaultPath over localStorage when defaultPath is provided", async () => {
const storedPath: PageInfo[] = [
{ coId: "co_stored" as CoID, name: "Stored" },
];
const defaultPath: PageInfo[] = [
{ coId: "co_default" as CoID, name: "Default" },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(storedPath));
function WrapperWithDefaultPath({ children }: PropsWithChildren) {
return (
{children}
);
}
const { result } = renderHook(() => useRouter(), {
wrapper: WrapperWithDefaultPath,
});
await waitFor(() => {
expect(result.current.path).toEqual(defaultPath);
});
});
it("should handle invalid JSON in localStorage gracefully", () => {
localStorage.setItem(STORAGE_KEY, "invalid json");
const consoleWarnSpy = vi
.spyOn(console, "warn")
.mockImplementation(() => {});
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
expect(consoleWarnSpy).toHaveBeenCalled();
expect(result.current.path).toEqual([]);
consoleWarnSpy.mockRestore();
});
it("should handle SSR scenario - component initializes with empty path when no defaultPath", () => {
// In SSR, window is undefined, so the initial state should be empty array
// We test this by ensuring the component works correctly without localStorage
localStorage.removeItem(STORAGE_KEY);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
// When no localStorage and no defaultPath, should initialize with empty array
expect(result.current.path).toEqual([]);
});
});
describe("localStorage persistence", () => {
it("should persist path changes to localStorage", async () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
const newPages: PageInfo[] = [
{ coId: "co_new1" as CoID, name: "New1" },
];
act(() => {
result.current.addPages(newPages);
});
await waitFor(() => {
const stored = localStorage.getItem(STORAGE_KEY);
expect(stored).toBeTruthy();
expect(JSON.parse(stored!)).toEqual(newPages);
});
});
it("should update localStorage when path changes", async () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
const firstPages: PageInfo[] = [
{ coId: "co_first" as CoID, name: "First" },
];
act(() => {
result.current.addPages(firstPages);
});
await waitFor(() => {
expect(result.current.path).toEqual(firstPages);
});
const secondPages: PageInfo[] = [
{ coId: "co_second" as CoID, name: "Second" },
];
act(() => {
result.current.addPages(secondPages);
});
await waitFor(() => {
const stored = localStorage.getItem(STORAGE_KEY);
const parsed = JSON.parse(stored!);
expect(parsed).toHaveLength(2);
expect(parsed[0]).toEqual(firstPages[0]);
expect(parsed[1]).toEqual(secondPages[0]);
});
});
});
describe("addPages", () => {
it("should add pages to the current path", async () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
expect(result.current.path).toEqual([]);
const newPages: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
{ coId: "co_page2" as CoID, name: "Page2" },
];
act(() => {
result.current.addPages(newPages);
});
await waitFor(() => {
expect(result.current.path).toEqual(newPages);
});
});
it("should append pages to existing path", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_initial" as CoID, name: "Initial" },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
const newPages: PageInfo[] = [
{ coId: "co_new" as CoID, name: "New" },
];
act(() => {
result.current.addPages(newPages);
});
await waitFor(() => {
expect(result.current.path).toHaveLength(2);
expect(result.current.path[0]).toEqual(initialPath[0]);
expect(result.current.path[1]).toEqual(newPages[0]);
});
});
it("should handle adding empty array", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_initial" as CoID, name: "Initial" },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.addPages([]);
});
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
});
});
describe("goToIndex", () => {
it("should navigate to a specific index", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
{ coId: "co_page2" as CoID, name: "Page2" },
{ coId: "co_page3" as CoID, name: "Page3" },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.goToIndex(1);
});
await waitFor(() => {
expect(result.current.path).toHaveLength(2);
expect(result.current.path[0]).toEqual(initialPath[0]);
expect(result.current.path[1]).toEqual(initialPath[1]);
});
});
it("should navigate to index 0", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
{ coId: "co_page2" as CoID, name: "Page2" },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.goToIndex(0);
});
await waitFor(() => {
expect(result.current.path).toHaveLength(1);
expect(result.current.path[0]).toEqual(initialPath[0]);
});
});
it("should handle going to last index", async () => {
const initialPath: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
{ coId: "co_page2" as CoID, name: "Page2" },
{ coId: "co_page3" as CoID, name: "Page3" },
];
function WrapperWithDefaultPath({ children }: PropsWithChildren) {
return (
{children}
);
}
const { result } = renderHook(() => useRouter(), {
wrapper: WrapperWithDefaultPath,
});
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.goToIndex(2);
});
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
});
it("should handle empty path", async () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
act(() => {
result.current.goToIndex(0);
});
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
});
});
describe("setPage", () => {
it("should set path to a single page with Root name", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_initial" as CoID, name: "Initial" },
{ coId: "co_initial2" as CoID, name: "Initial2" },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
const newCoId = "co_newroot" as CoID;
act(() => {
result.current.setPage(newCoId);
});
await waitFor(() => {
expect(result.current.path).toHaveLength(1);
expect(result.current.path[0]).toEqual({
coId: newCoId,
name: "Root",
});
});
});
it("should replace existing path", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_initial" as CoID, name: "Initial" },
{ coId: "co_initial2" as CoID, name: "Initial2" },
{ coId: "co_initial3" as CoID, name: "Initial3" },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
const newCoId = "co_newroot" as CoID;
act(() => {
result.current.setPage(newCoId);
});
await waitFor(() => {
expect(result.current.path).toHaveLength(1);
const firstPage = result.current.path[0];
expect(firstPage).toBeDefined();
expect(firstPage?.coId).toBe(newCoId);
expect(firstPage?.name).toBe("Root");
});
});
});
describe("goBack", () => {
it("should remove the last page from path", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
{ coId: "co_page2" as CoID, name: "Page2" },
{ coId: "co_page3" as CoID, name: "Page3" },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.goBack();
});
await waitFor(() => {
expect(result.current.path).toHaveLength(2);
expect(result.current.path[0]).toEqual(initialPath[0]);
expect(result.current.path[1]).toEqual(initialPath[1]);
});
});
it("should handle going back from single page", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.goBack();
});
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
});
it("should handle going back from empty path", async () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
act(() => {
result.current.goBack();
});
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
});
it("should handle multiple goBack calls", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
{ coId: "co_page2" as CoID, name: "Page2" },
{ coId: "co_page3" as CoID, name: "Page3" },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.goBack();
});
await waitFor(() => {
expect(result.current.path).toHaveLength(2);
});
act(() => {
result.current.goBack();
});
await waitFor(() => {
expect(result.current.path).toHaveLength(1);
expect(result.current.path[0]).toEqual(initialPath[0]);
});
});
});
describe("defaultPath synchronization", () => {
it("should update path when defaultPath changes", async () => {
const initialDefaultPath: PageInfo[] = [
{ coId: "co_initial" as CoID, name: "Initial" },
];
function WrapperWithInitialPath({ children }: PropsWithChildren) {
return (
{children}
);
}
const { result } = renderHook(() => useRouter(), {
wrapper: WrapperWithInitialPath,
});
await waitFor(() => {
expect(result.current.path).toEqual(initialDefaultPath);
});
const newDefaultPath: PageInfo[] = [
{ coId: "co_new" as CoID, name: "New" },
];
function WrapperWithNewPath({ children }: PropsWithChildren) {
return (
{children}
);
}
const { result: result2 } = renderHook(() => useRouter(), {
wrapper: WrapperWithNewPath,
});
await waitFor(() => {
expect(result2.current.path).toEqual(newDefaultPath);
});
});
it("should not update when defaultPath is the same", async () => {
const defaultPath: PageInfo[] = [
{ coId: "co_test" as CoID, name: "Test" },
];
function WrapperWithDefaultPath({ children }: PropsWithChildren) {
return (
{children}
);
}
const { result } = renderHook(() => useRouter(), {
wrapper: WrapperWithDefaultPath,
});
await waitFor(() => {
expect(result.current.path).toEqual(defaultPath);
});
const initialPath = result.current.path;
// Re-render with same wrapper - path should remain the same
const { result: result2 } = renderHook(() => useRouter(), {
wrapper: WrapperWithDefaultPath,
});
await waitFor(() => {
expect(result2.current.path).toEqual(initialPath);
});
});
it("should override manual changes when defaultPath changes", async () => {
const defaultPath: PageInfo[] = [
{ coId: "co_default" as CoID, name: "Default" },
];
function WrapperWithDefaultPath({ children }: PropsWithChildren) {
return (
{children}
);
}
const { result } = renderHook(() => useRouter(), {
wrapper: WrapperWithDefaultPath,
});
await waitFor(() => {
expect(result.current.path).toEqual(defaultPath);
});
const newDefaultPath: PageInfo[] = [
{ coId: "co_newdefault" as CoID, name: "NewDefault" },
];
function WrapperWithNewDefaultPath({ children }: PropsWithChildren) {
return (
{children}
);
}
const { result: result2 } = renderHook(() => useRouter(), {
wrapper: WrapperWithNewDefaultPath,
});
await waitFor(() => {
expect(result2.current.path).toEqual(newDefaultPath);
});
});
});
describe("router object stability", () => {
it("should provide stable router object reference", () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
expect(result.current).toBeTruthy();
expect(result.current.path).toBeDefined();
expect(result.current.addPages).toBeDefined();
expect(result.current.goToIndex).toBeDefined();
expect(result.current.setPage).toBeDefined();
expect(result.current.goBack).toBeDefined();
});
});
describe("integration scenarios", () => {
it("should handle complex navigation flow", async () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
const page1: PageInfo = {
coId: "co_page1" as CoID,
name: "Page1",
};
const page2: PageInfo = {
coId: "co_page2" as CoID,
name: "Page2",
};
const page3: PageInfo = {
coId: "co_page3" as CoID,
name: "Page3",
};
act(() => {
result.current.addPages([page1]);
});
await waitFor(() => {
expect(result.current.path).toEqual([page1]);
});
act(() => {
result.current.addPages([page2]);
});
await waitFor(() => {
expect(result.current.path).toEqual([page1, page2]);
});
act(() => {
result.current.addPages([page3]);
});
await waitFor(() => {
expect(result.current.path).toEqual([page1, page2, page3]);
});
act(() => {
result.current.goBack();
});
await waitFor(() => {
expect(result.current.path).toEqual([page1, page2]);
});
act(() => {
result.current.goToIndex(0);
});
await waitFor(() => {
expect(result.current.path).toEqual([page1]);
});
act(() => {
result.current.setPage("co_newroot" as CoID);
});
await waitFor(() => {
expect(result.current.path).toEqual([
{ coId: "co_newroot" as CoID, name: "Root" },
]);
});
});
it("should persist complex navigation flow to localStorage", async () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
const page1: PageInfo = {
coId: "co_page1" as CoID,
name: "Page1",
};
const page2: PageInfo = {
coId: "co_page2" as CoID,
name: "Page2",
};
act(() => {
result.current.addPages([page1]);
});
await waitFor(() => {
expect(result.current.path).toEqual([page1]);
});
act(() => {
result.current.addPages([page2]);
});
await waitFor(() => {
expect(result.current.path).toEqual([page1, page2]);
});
act(() => {
result.current.goBack();
});
await waitFor(() => {
const stored = localStorage.getItem(STORAGE_KEY);
const parsed = JSON.parse(stored!);
expect(parsed).toEqual([page1]);
});
});
});
});