232 lines
9.0 KiB
TypeScript
232 lines
9.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
import type { FileDiffMetadata } from "@pierre/diffs";
|
|
|
|
const processFileMock = vi.fn();
|
|
const requestFileContentMock = vi.fn();
|
|
const requestFileContentAtRefMock = vi.fn();
|
|
const getWebSocketClientMock = vi.fn(() => ({}) as unknown);
|
|
|
|
vi.mock("@pierre/diffs", () => ({
|
|
processFile: (...args: unknown[]) => processFileMock(...args),
|
|
}));
|
|
vi.mock("@/lib/ws/connection", () => ({
|
|
getWebSocketClient: () => getWebSocketClientMock(),
|
|
}));
|
|
vi.mock("@/lib/ws/workspace-files", () => ({
|
|
requestFileContent: (...args: unknown[]) => requestFileContentMock(...args),
|
|
requestFileContentAtRef: (...args: unknown[]) => requestFileContentAtRefMock(...args),
|
|
}));
|
|
|
|
type UseExpandableDiffFn = typeof import("./use-expandable-diff").useExpandableDiff;
|
|
|
|
let useExpandableDiff: UseExpandableDiffFn;
|
|
const FILE_PATH = "src/foo.ts";
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
processFileMock.mockReset();
|
|
requestFileContentMock.mockReset();
|
|
requestFileContentAtRefMock.mockReset();
|
|
getWebSocketClientMock.mockReset().mockReturnValue({});
|
|
({ useExpandableDiff } = await import("./use-expandable-diff"));
|
|
});
|
|
|
|
/** Build a minimal FileDiffMetadata where the trailing-context counts match. */
|
|
function consistentMeta(overrides: Partial<FileDiffMetadata> = {}): FileDiffMetadata {
|
|
return {
|
|
name: FILE_PATH,
|
|
type: "modified" as FileDiffMetadata["type"],
|
|
hunks: [
|
|
{
|
|
additionStart: 1,
|
|
additionCount: 3,
|
|
additionLines: 3,
|
|
additionLineIndex: 0,
|
|
deletionStart: 1,
|
|
deletionCount: 3,
|
|
deletionLines: 3,
|
|
deletionLineIndex: 0,
|
|
hunkContent: [],
|
|
},
|
|
],
|
|
// 5 lines total, hunk uses 3 starting at 0, leaves 2 trailing on each side
|
|
additionLines: ["a", "b", "c", "d", "e"],
|
|
deletionLines: ["a", "b", "c", "d", "e"],
|
|
splitLineCount: 0,
|
|
unifiedLineCount: 0,
|
|
isPartial: false,
|
|
...overrides,
|
|
} as FileDiffMetadata;
|
|
}
|
|
|
|
/** Build a FileDiffMetadata whose trailing additions vs deletions disagree. */
|
|
function inconsistentMeta(): FileDiffMetadata {
|
|
const m = consistentMeta();
|
|
// Same hunk indices/counts, but extend deletionLines so trailing remainders
|
|
// differ (2 trailing additions vs 4 trailing deletions). This is exactly
|
|
// the shape @pierre/diffs' iterateOverDiff asserts on and throws over.
|
|
return { ...m, deletionLines: [...m.deletionLines, "f", "g"] };
|
|
}
|
|
|
|
const PARTIAL = consistentMeta({ isPartial: true });
|
|
|
|
const baseProps = {
|
|
sessionId: "sess-1",
|
|
filePath: FILE_PATH,
|
|
baseRef: "HEAD",
|
|
diff: "diff --git a/src/foo.ts b/src/foo.ts\n--- a/src/foo.ts\n+++ b/src/foo.ts\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n",
|
|
enableExpansion: true,
|
|
};
|
|
|
|
async function loadAndSettle(loadContent: () => Promise<void>) {
|
|
await act(async () => {
|
|
await loadContent();
|
|
});
|
|
}
|
|
|
|
describe("useExpandableDiff", () => {
|
|
it("returns the partial fileDiffMetadata before content is loaded", () => {
|
|
const { result } = renderHook(() =>
|
|
useExpandableDiff({ ...baseProps, fileDiffMetadata: PARTIAL }),
|
|
);
|
|
expect(result.current.metadata).toBe(PARTIAL);
|
|
expect(result.current.isContentLoaded).toBe(false);
|
|
});
|
|
|
|
it("returns the reparsed metadata when trailing-context counts agree", async () => {
|
|
requestFileContentMock.mockResolvedValue({ content: "new", is_binary: false });
|
|
requestFileContentAtRefMock.mockResolvedValue({ content: "old", is_binary: false });
|
|
const reparsed = consistentMeta();
|
|
processFileMock.mockReturnValue(reparsed);
|
|
|
|
const { result } = renderHook(() =>
|
|
useExpandableDiff({ ...baseProps, fileDiffMetadata: PARTIAL }),
|
|
);
|
|
await loadAndSettle(result.current.loadContent);
|
|
expect(result.current.metadata).toBe(reparsed);
|
|
});
|
|
|
|
it("scopes both content requests to the file repository", async () => {
|
|
requestFileContentMock.mockResolvedValue({ content: "new", is_binary: false });
|
|
requestFileContentAtRefMock.mockResolvedValue({ content: "old", is_binary: false });
|
|
processFileMock.mockReturnValue(consistentMeta());
|
|
|
|
const { result } = renderHook(() =>
|
|
useExpandableDiff({ ...baseProps, fileDiffMetadata: PARTIAL, repo: "frontend" }),
|
|
);
|
|
await loadAndSettle(result.current.loadContent);
|
|
|
|
expect(requestFileContentMock).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"sess-1",
|
|
FILE_PATH,
|
|
"frontend",
|
|
);
|
|
expect(requestFileContentAtRefMock).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"sess-1",
|
|
FILE_PATH,
|
|
"HEAD",
|
|
"frontend",
|
|
);
|
|
});
|
|
|
|
it("falls back to the partial metadata when the reparse is inconsistent", async () => {
|
|
requestFileContentMock.mockResolvedValue({ content: "new", is_binary: false });
|
|
requestFileContentAtRefMock.mockResolvedValue({ content: "new", is_binary: false });
|
|
processFileMock.mockReturnValue(inconsistentMeta());
|
|
|
|
const { result } = renderHook(() =>
|
|
useExpandableDiff({ ...baseProps, fileDiffMetadata: PARTIAL }),
|
|
);
|
|
await loadAndSettle(result.current.loadContent);
|
|
expect(result.current.metadata).toBe(PARTIAL);
|
|
// canExpand must be false: clicking the toolbar button after a rejected
|
|
// reparse is a silent no-op because the partial metadata can't drive
|
|
// @pierre/diffs' iterateOverDiff expansion path.
|
|
expect(result.current.canExpand).toBe(false);
|
|
});
|
|
|
|
it("preserves the lang override from the partial metadata on a successful reparse", async () => {
|
|
requestFileContentMock.mockResolvedValue({ content: "new", is_binary: false });
|
|
requestFileContentAtRefMock.mockResolvedValue({ content: "old", is_binary: false });
|
|
processFileMock.mockReturnValue(consistentMeta({ lang: "go" as never }));
|
|
|
|
const overridden = consistentMeta({ isPartial: true, lang: "text" as never });
|
|
const { result } = renderHook(() =>
|
|
useExpandableDiff({ ...baseProps, fileDiffMetadata: overridden }),
|
|
);
|
|
await loadAndSettle(result.current.loadContent);
|
|
expect((result.current.metadata as { lang?: string }).lang).toBe("text");
|
|
});
|
|
|
|
it("returns null when no fileDiffMetadata was provided", () => {
|
|
const { result } = renderHook(() =>
|
|
useExpandableDiff({ ...baseProps, fileDiffMetadata: null }),
|
|
);
|
|
expect(result.current.metadata).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("useExpandableDiff — content-load retries", () => {
|
|
it("retries a transient WebSocket timeout and succeeds on a later attempt", async () => {
|
|
// A single 5s WS timeout used to set a terminal error and permanently
|
|
// disable expansion for this mount (the auto-load effect only fires while
|
|
// there is no error). The load now retries transient failures, so a brief
|
|
// backend stall recovers instead of killing expansion.
|
|
requestFileContentMock
|
|
.mockRejectedValueOnce(new Error("WebSocket request timed out: workspace.file.get"))
|
|
.mockResolvedValue({ content: "new", is_binary: false });
|
|
requestFileContentAtRefMock.mockResolvedValue({ content: "old", is_binary: false });
|
|
const reparsed = consistentMeta();
|
|
processFileMock.mockReturnValue(reparsed);
|
|
|
|
const { result } = renderHook(() =>
|
|
useExpandableDiff({ ...baseProps, fileDiffMetadata: PARTIAL }),
|
|
);
|
|
await loadAndSettle(result.current.loadContent);
|
|
|
|
expect(requestFileContentMock).toHaveBeenCalledTimes(2);
|
|
expect(result.current.error).toBeNull();
|
|
expect(result.current.metadata).toBe(reparsed);
|
|
expect(result.current.isContentLoaded).toBe(true);
|
|
});
|
|
|
|
it("does not retry a permanent error and falls back to the partial metadata", async () => {
|
|
// Binary files can never be expanded; retrying would just spin. The load
|
|
// surfaces the error on the first attempt and keeps the partial metadata.
|
|
requestFileContentMock.mockRejectedValue(new Error("Cannot expand binary files"));
|
|
requestFileContentAtRefMock.mockResolvedValue({ content: "old", is_binary: false });
|
|
|
|
const { result } = renderHook(() =>
|
|
useExpandableDiff({ ...baseProps, fileDiffMetadata: PARTIAL }),
|
|
);
|
|
await loadAndSettle(result.current.loadContent);
|
|
|
|
expect(requestFileContentMock).toHaveBeenCalledTimes(1);
|
|
expect(result.current.error).toBe("Cannot expand binary files");
|
|
expect(result.current.metadata).toBe(PARTIAL);
|
|
expect(result.current.canExpand).toBe(false);
|
|
});
|
|
|
|
it("surfaces a terminal error after transient retries are exhausted", async () => {
|
|
// Four attempts total (initial + 3 retries); when every one times out the
|
|
// load gives up with the error so the caller falls back to partial metadata.
|
|
requestFileContentMock.mockRejectedValue(
|
|
new Error("WebSocket request timed out: workspace.file.get"),
|
|
);
|
|
requestFileContentAtRefMock.mockResolvedValue({ content: "old", is_binary: false });
|
|
|
|
const { result } = renderHook(() =>
|
|
useExpandableDiff({ ...baseProps, fileDiffMetadata: PARTIAL }),
|
|
);
|
|
await loadAndSettle(result.current.loadContent);
|
|
|
|
expect(requestFileContentMock).toHaveBeenCalledTimes(4);
|
|
expect(result.current.error).toContain("timed out");
|
|
expect(result.current.metadata).toBe(PARTIAL);
|
|
expect(result.current.canExpand).toBe(false);
|
|
});
|
|
});
|