163 lines
4.9 KiB
TypeScript
163 lines
4.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
import { useRef } from "react";
|
|
import type { FileEditorState } from "@/lib/state/dockview-store";
|
|
|
|
const mockUpdateFileContent = vi.fn();
|
|
const mockDeleteFile = vi.fn();
|
|
const mockGetWebSocketClient = vi.fn();
|
|
const mockSaveDocument = vi.fn();
|
|
let openFilesMap = new Map<string, FileEditorState>();
|
|
|
|
vi.mock("@/lib/ws/workspace-files", () => ({
|
|
updateFileContent: (...args: unknown[]) => mockUpdateFileContent(...args),
|
|
deleteFile: (...args: unknown[]) => mockDeleteFile(...args),
|
|
}));
|
|
|
|
vi.mock("@/lib/ws/connection", () => ({
|
|
getWebSocketClient: () => mockGetWebSocketClient(),
|
|
}));
|
|
|
|
vi.mock("@/lib/lsp/lsp-client-manager", () => ({
|
|
lspClientManager: {
|
|
saveDocument: (...args: unknown[]) => mockSaveDocument(...args),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/state/dockview-store", () => ({
|
|
useDockviewStore: {
|
|
getState: () => ({ openFiles: openFilesMap, api: null }),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/utils/file-diff", () => ({
|
|
generateUnifiedDiff: () => "@@ -1 +1 @@\n-v1\n+v2",
|
|
calculateHash: async (s: string) => `h:${s.length}`,
|
|
}));
|
|
|
|
import { useSaveDeleteActions, type SaveDeleteParams } from "./use-file-save-delete";
|
|
import { buildRepoScopedItemId } from "@/lib/state/dockview-panel-actions";
|
|
|
|
const FAKE_CLIENT = {} as ReturnType<typeof import("@/lib/ws/connection").getWebSocketClient>;
|
|
const SESSION_ID = "sess-1";
|
|
const PATH = "src/foo.ts";
|
|
const REPO = "enrichment-commons";
|
|
|
|
function seedOpenFile(state: Partial<FileEditorState> = {}) {
|
|
const key = buildRepoScopedItemId(PATH, state.repo);
|
|
openFilesMap = new Map<string, FileEditorState>([
|
|
[
|
|
key,
|
|
{
|
|
path: PATH,
|
|
name: "foo.ts",
|
|
content: "v2",
|
|
originalContent: "v1",
|
|
originalHash: "h:2",
|
|
isDirty: true,
|
|
...state,
|
|
},
|
|
],
|
|
]);
|
|
}
|
|
|
|
function renderActions() {
|
|
return renderHook(() => {
|
|
const activeSessionIdRef = useRef<string | null>(SESSION_ID);
|
|
const params: SaveDeleteParams = {
|
|
activeSessionIdRef,
|
|
updateFileState: vi.fn(),
|
|
setSavingFiles: vi.fn(),
|
|
toast: vi.fn(),
|
|
};
|
|
return useSaveDeleteActions(params);
|
|
});
|
|
}
|
|
|
|
describe("useSaveDeleteActions repo threading", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockGetWebSocketClient.mockReturnValue(FAKE_CLIENT);
|
|
});
|
|
|
|
it("saveFile forwards the file's repo so the diff applies under the right repository", async () => {
|
|
// Multi-repo task: editing foo.ts inside "enrichment-commons" and saving
|
|
// must scope the update to that repo, else the backend writes against the
|
|
// bare task root and the save fails with "file not found".
|
|
seedOpenFile({ repo: REPO });
|
|
mockUpdateFileContent.mockResolvedValueOnce({ success: true, new_hash: "h:2" });
|
|
|
|
const { result } = renderActions();
|
|
await act(async () => {
|
|
await result.current.saveFile(PATH, REPO);
|
|
});
|
|
|
|
expect(mockUpdateFileContent).toHaveBeenCalledWith(
|
|
FAKE_CLIENT,
|
|
SESSION_ID,
|
|
expect.objectContaining({ path: PATH, repo: REPO }),
|
|
);
|
|
expect(mockSaveDocument).toHaveBeenCalledWith(SESSION_ID, PATH, REPO, "v2", "v2");
|
|
});
|
|
|
|
it("does not notify the language server when saving fails", async () => {
|
|
seedOpenFile({ repo: REPO });
|
|
mockUpdateFileContent.mockResolvedValueOnce({ success: false, error: "write failed" });
|
|
|
|
const { result } = renderActions();
|
|
await act(async () => {
|
|
await result.current.saveFile(PATH, REPO);
|
|
});
|
|
|
|
expect(mockSaveDocument).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the newest editor snapshot in LSP when the buffer advances during save", async () => {
|
|
seedOpenFile({ repo: REPO });
|
|
let resolveSave!: (response: { success: boolean; new_hash: string }) => void;
|
|
mockUpdateFileContent.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveSave = resolve;
|
|
}),
|
|
);
|
|
const { result } = renderActions();
|
|
|
|
let savePromise!: Promise<void>;
|
|
act(() => {
|
|
savePromise = result.current.saveFile(PATH, REPO);
|
|
});
|
|
const fileKey = buildRepoScopedItemId(PATH, REPO);
|
|
const savingSnapshot = openFilesMap.get(fileKey)!;
|
|
openFilesMap.set(fileKey, {
|
|
...savingSnapshot,
|
|
content: "v3 typed while saving",
|
|
isDirty: true,
|
|
});
|
|
|
|
await act(async () => {
|
|
resolveSave({ success: true, new_hash: "h:v2" });
|
|
await savePromise;
|
|
});
|
|
|
|
expect(mockSaveDocument).toHaveBeenCalledWith(
|
|
SESSION_ID,
|
|
PATH,
|
|
REPO,
|
|
"v2",
|
|
"v3 typed while saving",
|
|
);
|
|
});
|
|
|
|
it("deleteFile forwards the file's repo", async () => {
|
|
seedOpenFile({ repo: REPO });
|
|
mockDeleteFile.mockResolvedValueOnce({ success: true });
|
|
|
|
const { result } = renderActions();
|
|
await act(async () => {
|
|
await result.current.deleteFileAction(PATH, REPO);
|
|
});
|
|
|
|
expect(mockDeleteFile).toHaveBeenCalledWith(FAKE_CLIENT, SESSION_ID, PATH, REPO);
|
|
});
|
|
});
|