Files
fusion/packages/dashboard/app/hooks/__tests__/useProjects.test.ts

376 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useProjects } from "../useProjects";
import * as api from "../../api";
import type { ProjectInfo } from "../../api";
vi.mock("../../api", () => ({
fetchProjects: vi.fn(),
registerProject: vi.fn(),
unregisterProject: vi.fn(),
updateProject: vi.fn(),
}));
const mockFetchProjects = vi.mocked(api.fetchProjects);
const mockUpdateProject = vi.mocked(api.updateProject);
const mockRegisterProject = vi.mocked(api.registerProject);
const mockUnregisterProject = vi.mocked(api.unregisterProject);
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("useProjects", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchProjects.mockReset();
mockUpdateProject.mockReset();
mockRegisterProject.mockReset();
mockUnregisterProject.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
describe("visibility change", () => {
let originalVisibilityState: PropertyDescriptor | undefined;
beforeEach(() => {
originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
});
afterEach(() => {
if (originalVisibilityState) {
Object.defineProperty(document, "visibilityState", originalVisibilityState);
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (document as any).visibilityState;
}
});
function setVisibilityState(state: "visible" | "hidden") {
Object.defineProperty(document, "visibilityState", {
value: state,
writable: true,
configurable: true,
});
}
async function dispatchVisibilityChange() {
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
await Promise.resolve();
});
}
it("refetches projects when visibility changes from hidden to visible", async () => {
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const initialProject: ProjectInfo = {
id: "proj_001",
name: "Initial Project",
path: "/initial/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const refreshedProject: ProjectInfo = {
id: "proj_001",
name: "Updated Project",
path: "/initial/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
mockFetchProjects.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
const { result } = renderHook(() => useProjects());
await act(async () => {
await flushPromises();
});
expect(result.current.projects).toHaveLength(1);
expect(result.current.projects[0].name).toBe("Initial Project");
vi.setSystemTime(new Date("2026-01-01T00:00:01.100Z"));
setVisibilityState("hidden");
await dispatchVisibilityChange();
setVisibilityState("visible");
await dispatchVisibilityChange();
await act(async () => {
await flushPromises();
});
expect(result.current.projects[0].name).toBe("Updated Project");
expect(mockFetchProjects).toHaveBeenCalledTimes(2);
});
it("does not refetch when visibility changes to hidden", async () => {
const initialProject: ProjectInfo = {
id: "proj_001",
name: "Test Project",
path: "/test/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
mockFetchProjects.mockResolvedValueOnce([initialProject]);
renderHook(() => useProjects());
await act(async () => {
await flushPromises();
});
mockFetchProjects.mockClear();
setVisibilityState("hidden");
await dispatchVisibilityChange();
expect(mockFetchProjects).not.toHaveBeenCalled();
});
it("debounces rapid visibility changes (minimum 1 second between fetches)", async () => {
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const initialProject: ProjectInfo = {
id: "proj_001",
name: "Test Project",
path: "/test/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
mockFetchProjects.mockResolvedValue([initialProject]);
renderHook(() => useProjects());
await act(async () => {
await flushPromises();
});
mockFetchProjects.mockClear();
vi.setSystemTime(new Date("2026-01-01T00:00:01.100Z"));
setVisibilityState("hidden");
await dispatchVisibilityChange();
setVisibilityState("visible");
await dispatchVisibilityChange();
expect(mockFetchProjects).toHaveBeenCalledTimes(1);
for (let i = 0; i < 5; i++) {
setVisibilityState("hidden");
await dispatchVisibilityChange();
setVisibilityState("visible");
await dispatchVisibilityChange();
}
expect(mockFetchProjects).toHaveBeenCalledTimes(1);
vi.setSystemTime(new Date("2026-01-01T00:00:02.200Z"));
setVisibilityState("hidden");
await dispatchVisibilityChange();
setVisibilityState("visible");
await dispatchVisibilityChange();
expect(mockFetchProjects).toHaveBeenCalledTimes(2);
});
it("cleans up visibility change listener on unmount", async () => {
mockFetchProjects.mockResolvedValueOnce([]);
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
const { unmount } = renderHook(() => useProjects());
await waitFor(() => {
expect(mockFetchProjects).toHaveBeenCalledTimes(1);
});
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
removeEventListenerSpy.mockRestore();
});
});
describe("basic functionality", () => {
it("fetches projects on mount", async () => {
const mockProjects: ProjectInfo[] = [
{
id: "proj_001",
name: "Test Project",
path: "/test/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchProjects.mockResolvedValueOnce(mockProjects);
const { result } = renderHook(() => useProjects());
await act(async () => {
await flushPromises();
});
expect(result.current.loading).toBe(false);
expect(result.current.projects).toHaveLength(1);
expect(result.current.projects[0].name).toBe("Test Project");
});
it("handles errors gracefully", async () => {
mockFetchProjects.mockRejectedValueOnce(new Error("Failed to fetch"));
const { result } = renderHook(() => useProjects());
await act(async () => {
await flushPromises();
});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe("Failed to fetch");
});
it("register adds project optimistically", async () => {
const newProject: ProjectInfo = {
id: "proj_new",
name: "New Project",
path: "/new/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
mockFetchProjects.mockResolvedValueOnce([]);
mockRegisterProject.mockResolvedValueOnce(newProject);
const { result } = renderHook(() => useProjects());
await act(async () => {
await flushPromises();
});
expect(result.current.projects).toHaveLength(0);
await act(async () => {
await result.current.register({ name: "New Project", path: "/new/path" });
});
expect(result.current.projects).toHaveLength(1);
expect(result.current.projects[0].id).toBe("proj_new");
});
it("unregister removes project optimistically", async () => {
const mockProjects: ProjectInfo[] = [
{
id: "proj_001",
name: "Test Project",
path: "/test/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchProjects.mockResolvedValueOnce(mockProjects);
mockUnregisterProject.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useProjects());
await act(async () => {
await flushPromises();
});
expect(result.current.projects).toHaveLength(1);
await act(async () => {
await result.current.unregister("proj_001");
});
expect(result.current.projects).toHaveLength(0);
});
it("update modifies project optimistically", async () => {
const mockProjects: ProjectInfo[] = [
{
id: "proj_001",
name: "Test Project",
path: "/test/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
const updatedProject: ProjectInfo = {
...mockProjects[0],
name: "Updated Name",
};
mockFetchProjects.mockResolvedValueOnce(mockProjects);
mockUpdateProject.mockResolvedValueOnce(updatedProject);
const { result } = renderHook(() => useProjects());
await act(async () => {
await flushPromises();
});
expect(result.current.projects[0].name).toBe("Test Project");
await act(async () => {
await result.current.update("proj_001", { name: "Updated Name" });
});
expect(result.current.projects[0].name).toBe("Updated Name");
});
it("refresh manually refetches projects", async () => {
const initialProject: ProjectInfo = {
id: "proj_001",
name: "Initial",
path: "/test/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const refreshedProject: ProjectInfo = {
...initialProject,
name: "Refreshed",
};
mockFetchProjects.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
const { result } = renderHook(() => useProjects());
await act(async () => {
await flushPromises();
});
expect(result.current.projects[0].name).toBe("Initial");
await act(async () => {
await result.current.refresh();
});
expect(result.current.projects[0].name).toBe("Refreshed");
});
});
});