Files
kandev/apps/web/components/diff/use-expandable-diff.test.ts
T

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);
});
});