test(FN-1282): expand dashboard hook and modal coverage
- Add tests for useAgents and useProjectHealth hooks, including loading, success, and error flows - Add useFileBrowser and useFileEditor hook tests for file navigation, save behavior, and edge cases - Add component tests for AgentImportModal and AgentGenerationModal interaction paths - Add MissionInterviewModal tests covering question flow, submission, and state transitions - Stabilize dashboard test suite cleanup and mocking patterns to reduce flakiness
This commit is contained in:
275
packages/dashboard/app/hooks/__tests__/useFileEditor.test.ts
Normal file
275
packages/dashboard/app/hooks/__tests__/useFileEditor.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useFileEditor } from "../useFileEditor";
|
||||
import * as api from "../../api";
|
||||
import type { FileContentResponse, SaveFileResponse } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchFileContent: vi.fn(),
|
||||
saveFileContent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchFileContent = vi.mocked(api.fetchFileContent);
|
||||
const mockSaveFileContent = vi.mocked(api.saveFileContent);
|
||||
|
||||
function contentResponse(content: string, mtime = "2026-01-01T00:00:00.000Z"): FileContentResponse {
|
||||
return {
|
||||
content,
|
||||
mtime,
|
||||
size: content.length,
|
||||
};
|
||||
}
|
||||
|
||||
function saveResponse(mtime = "2026-01-02T00:00:00.000Z", size = 100): SaveFileResponse {
|
||||
return {
|
||||
success: true,
|
||||
mtime,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("useFileEditor", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchFileContent.mockReset();
|
||||
mockSaveFileContent.mockReset();
|
||||
mockFetchFileContent.mockResolvedValue(contentResponse("hello"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty content when disabled or filePath is null", async () => {
|
||||
const disabled = renderHook(() => useFileEditor("FN-001", "README.md", false));
|
||||
expect(disabled.result.current.content).toBe("");
|
||||
expect(disabled.result.current.originalContent).toBe("");
|
||||
expect(disabled.result.current.mtime).toBeNull();
|
||||
|
||||
const noPath = renderHook(() => useFileEditor("FN-001", null, true));
|
||||
expect(noPath.result.current.content).toBe("");
|
||||
expect(noPath.result.current.originalContent).toBe("");
|
||||
expect(noPath.result.current.mtime).toBeNull();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(mockFetchFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches file content when enabled and filePath is set", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("file body"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "README.md", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.content).toBe("file body");
|
||||
});
|
||||
|
||||
expect(mockFetchFileContent).toHaveBeenCalledWith("FN-001", "README.md");
|
||||
});
|
||||
|
||||
it("sets content and originalContent from fetch response", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("abc"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "a.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe("abc");
|
||||
expect(result.current.originalContent).toBe("abc");
|
||||
});
|
||||
});
|
||||
|
||||
it("sets mtime from fetchFileContent response", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("abc", "2026-02-01T10:00:00.000Z"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "a.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.mtime).toBe("2026-02-01T10:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles fetch error by setting error and clearing content/mtime", async () => {
|
||||
mockFetchFileContent.mockRejectedValueOnce(new Error("load failed"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "missing.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe("load failed");
|
||||
});
|
||||
|
||||
expect(result.current.content).toBe("");
|
||||
expect(result.current.originalContent).toBe("");
|
||||
expect(result.current.mtime).toBeNull();
|
||||
});
|
||||
|
||||
it("setContent updates content and clears error", async () => {
|
||||
mockFetchFileContent.mockRejectedValueOnce(new Error("broken"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "bad.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe("broken");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("new content");
|
||||
});
|
||||
|
||||
expect(result.current.content).toBe("new content");
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("hasChanges is false when content equals originalContent", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("same"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "same.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("hasChanges is true when content differs from originalContent", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("start"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "edit.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe("start");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("changed");
|
||||
});
|
||||
|
||||
expect(result.current.hasChanges).toBe(true);
|
||||
});
|
||||
|
||||
it("save calls saveFileContent with taskId, filePath, and current content", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("original"));
|
||||
mockSaveFileContent.mockResolvedValueOnce(saveResponse());
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "file.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("updated");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(mockSaveFileContent).toHaveBeenCalledWith("FN-001", "file.txt", "updated");
|
||||
});
|
||||
|
||||
it("save updates originalContent and mtime after success", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("before", "2026-01-01T00:00:00.000Z"));
|
||||
mockSaveFileContent.mockResolvedValueOnce(saveResponse("2026-02-01T00:00:00.000Z"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "file.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("after");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(result.current.originalContent).toBe("after");
|
||||
expect(result.current.mtime).toBe("2026-02-01T00:00:00.000Z");
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
});
|
||||
|
||||
it("save sets error on failure and re-throws", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("before"));
|
||||
mockSaveFileContent.mockRejectedValueOnce(new Error("save failed"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "file.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("changed");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.save()).rejects.toThrow("save failed");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe("save failed");
|
||||
});
|
||||
});
|
||||
|
||||
it("save is a no-op when hasChanges is false", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("same"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "file.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(mockSaveFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels in-flight load on filePath change and ignores stale response", async () => {
|
||||
const first = deferred<FileContentResponse>();
|
||||
const second = deferred<FileContentResponse>();
|
||||
|
||||
mockFetchFileContent
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ filePath }) => useFileEditor("FN-001", filePath, true),
|
||||
{ initialProps: { filePath: "first.txt" as string | null } },
|
||||
);
|
||||
|
||||
rerender({ filePath: "second.txt" });
|
||||
|
||||
second.resolve(contentResponse("second-content", "2026-03-02T00:00:00.000Z"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe("second-content");
|
||||
expect(result.current.mtime).toBe("2026-03-02T00:00:00.000Z");
|
||||
});
|
||||
|
||||
first.resolve(contentResponse("stale-first", "2026-03-01T00:00:00.000Z"));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(result.current.content).toBe("second-content");
|
||||
expect(result.current.mtime).toBe("2026-03-02T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user