376 lines
11 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|