import React from "react"; import { act, renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; import AgentServerGitService from "#/api/git-service/agent-server-git-service.api"; import type { GitChange } from "#/api/open-hands.types"; import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; import { useUnifiedGetGitChanges } from "#/hooks/query/use-unified-get-git-changes"; const mocks = vi.hoisted(() => ({ conversationId: "conversation-1" as string | undefined, conversation: undefined as AppConversation | undefined, runtimeIsReady: true, })); vi.mock("#/hooks/use-conversation-id", () => ({ useConversationId: () => ({ conversationId: mocks.conversationId }), })); vi.mock("#/hooks/query/use-active-conversation", () => ({ useActiveConversation: () => ({ data: mocks.conversation }), })); vi.mock("#/hooks/use-runtime-is-ready", () => ({ useRuntimeIsReady: () => mocks.runtimeIsReady, })); const getGitChanges = vi.spyOn(AgentServerGitService, "getGitChanges"); const makeConversation = ( overrides: Partial = {}, ): AppConversation => ({ id: "conversation-1", created_by_user_id: "user-1", selected_repository: "OpenHands/agent-canvas", selected_branch: "main", git_provider: "github", title: "Mutation-tested changes", trigger: "gui", pr_number: [], llm_model: null, metrics: null, created_at: "2026-07-13T00:00:00.000Z", updated_at: "2026-07-13T00:00:00.000Z", execution_status: null, conversation_url: "https://runtime.example.test/conversations/conversation-1", session_api_key: "session-key", sandbox_id: "sandbox-1", workspace: { working_dir: "/workspace/agent-canvas" }, sub_conversation_ids: [], ...overrides, }); const makeChange = ( path: string, status: GitChange["status"] = "M", ): GitChange => ({ path, status }); function makeDeferred() { const resolver = { current: (_value: T) => {} }; const promise = new Promise((resolve) => { resolver.current = resolve; }); return { promise, resolve: (value: T) => resolver.current(value), }; } function prepareHook( overrides: { conversationId?: string | undefined; conversation?: AppConversation | undefined; runtimeIsReady?: boolean; } = {}, ) { getGitChanges.mockReset(); mocks.conversationId = Object.hasOwn(overrides, "conversationId") ? overrides.conversationId : "conversation-1"; mocks.conversation = Object.hasOwn(overrides, "conversation") ? overrides.conversation : makeConversation(); mocks.runtimeIsReady = overrides.runtimeIsReady ?? true; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Number.POSITIVE_INFINITY, }, }, }); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); return { queryClient, wrapper }; } describe("git change loading and ordering", () => { it("loads the current workspace changes with runtime credentials", async () => { const changes = [ makeChange("src/new-file.ts", "A"), makeChange("src/existing.ts"), ]; const { wrapper } = prepareHook(); getGitChanges.mockResolvedValue(changes); const { result } = renderHook(() => useUnifiedGetGitChanges(), { wrapper, }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current).toMatchObject({ data: changes, isLoading: false, isFetching: false, isSuccess: true, isError: false, error: null, }); expect(result.current.refetch).toEqual(expect.any(Function)); expect(getGitChanges).toHaveBeenCalledOnce(); expect(getGitChanges).toHaveBeenCalledWith( "conversation-1", "https://runtime.example.test/conversations/conversation-1", "session-key", "/workspace/agent-canvas", ); }); it("derives the git path from the selected repository when the workspace is blank", async () => { const { wrapper } = prepareHook({ conversation: makeConversation({ selected_repository: "OpenHands/software-agent-sdk", workspace: { working_dir: " " }, }), }); getGitChanges.mockResolvedValue([]); const { result } = renderHook(() => useUnifiedGetGitChanges(), { wrapper, }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(getGitChanges).toHaveBeenCalledWith( "conversation-1", expect.any(String), "session-key", "workspace/project/software-agent-sdk", ); expect(result.current.data).toEqual([]); }); it("uses the default git path while conversation metadata is still unavailable", async () => { const { wrapper } = prepareHook({ conversation: undefined }); getGitChanges.mockResolvedValue([makeChange("README.md")]); const { result } = renderHook(() => useUnifiedGetGitChanges(), { wrapper, }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(getGitChanges).toHaveBeenCalledWith( "conversation-1", undefined, undefined, "workspace/project", ); }); it("uses the selected repository when its workspace has no working directory", async () => { const { wrapper } = prepareHook({ conversation: makeConversation({ selected_repository: "OpenHands/openhands", workspace: { working_dir: null }, }), }); getGitChanges.mockResolvedValue([]); const { result } = renderHook(() => useUnifiedGetGitChanges(), { wrapper, }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(getGitChanges).toHaveBeenCalledWith( "conversation-1", expect.any(String), "session-key", "workspace/project/openhands", ); }); it("does not contact the backend until the runtime is ready", async () => { const { wrapper } = prepareHook({ runtimeIsReady: false }); const { result } = renderHook(() => useUnifiedGetGitChanges(), { wrapper, }); await act(async () => { await Promise.resolve(); }); expect(getGitChanges).not.toHaveBeenCalled(); expect(result.current).toMatchObject({ data: [], isLoading: false, isFetching: false, isSuccess: false, isError: false, }); }); it("reports a clear error if a disabled query is manually run without a conversation id", async () => { const { wrapper } = prepareHook({ conversationId: undefined }); const { result } = renderHook(() => useUnifiedGetGitChanges(), { wrapper, }); await act(async () => { await result.current.refetch(); }); await waitFor(() => expect(result.current.isError).toBe(true)); expect(result.current.error).toEqual(new Error("No conversation ID")); expect(getGitChanges).not.toHaveBeenCalled(); }); it("puts newly discovered files first and removes files absent from a refresh", async () => { const originalChanges = [ makeChange("src/removed.ts", "D"), makeChange("src/kept.ts"), ]; const refreshedChanges = [ makeChange("src/kept.ts"), makeChange("src/new.ts", "A"), ]; const { wrapper } = prepareHook(); getGitChanges .mockResolvedValueOnce(originalChanges) .mockResolvedValueOnce(refreshedChanges); const { result } = renderHook(() => useUnifiedGetGitChanges(), { wrapper, }); await waitFor(() => expect(result.current.data).toEqual(originalChanges)); await act(async () => { await result.current.refetch(); }); await waitFor(() => expect(result.current.data).toEqual([ makeChange("src/new.ts", "A"), makeChange("src/kept.ts"), ]), ); expect(getGitChanges).toHaveBeenCalledTimes(2); }); it("ignores a refreshed payload when the backend returns the same array reference", async () => { const sharedChanges = [makeChange("src/original.ts")]; const refresh = makeDeferred(); const { wrapper } = prepareHook(); getGitChanges .mockResolvedValueOnce(sharedChanges) .mockReturnValueOnce(refresh.promise); const { result } = renderHook(() => useUnifiedGetGitChanges(), { wrapper, }); await waitFor(() => expect(result.current.data).toEqual(sharedChanges)); sharedChanges.push(makeChange("src/mutated-in-place.ts", "A")); act(() => { void result.current.refetch(); }); await waitFor(() => expect(result.current.isFetching).toBe(true)); await act(async () => { refresh.resolve(sharedChanges); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); expect(getGitChanges).toHaveBeenCalledTimes(2); expect(result.current.data).toEqual([makeChange("src/original.ts")]); }); it("normalizes a single change returned by a legacy backend", async () => { const singleChange = makeChange("legacy.txt", "U"); const { wrapper } = prepareHook(); getGitChanges.mockResolvedValue( singleChange as unknown as Awaited< ReturnType >, ); const { result } = renderHook(() => useUnifiedGetGitChanges(), { wrapper, }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual([singleChange]); }); it("surfaces backend failures without retrying", async () => { const backendError = new Error("runtime unavailable"); const { queryClient, wrapper } = prepareHook(); getGitChanges.mockRejectedValue(backendError); const { result } = renderHook(() => useUnifiedGetGitChanges(), { wrapper, }); await waitFor(() => expect(result.current.isError).toBe(true)); expect(result.current.error).toBe(backendError); expect(result.current.data).toEqual([]); expect(getGitChanges).toHaveBeenCalledOnce(); const query = queryClient.getQueryCache().find({ queryKey: [ "file_changes", "conversation-1", "https://runtime.example.test/conversations/conversation-1", "session-key", "/workspace/agent-canvas", ], }); expect(query?.options).toMatchObject({ retry: false, staleTime: 300_000, gcTime: 900_000, refetchOnMount: "always", meta: { disableToast: true }, }); }); it("keeps query caches isolated across every runtime identity field", async () => { const { wrapper } = prepareHook(); getGitChanges.mockImplementation(async () => [ makeChange(`response-${getGitChanges.mock.calls.length}.txt`), ]); const { rerender } = renderHook(() => useUnifiedGetGitChanges(), { wrapper, }); await waitFor(() => expect(getGitChanges).toHaveBeenCalledTimes(1)); mocks.conversationId = "conversation-2"; rerender(); await waitFor(() => expect(getGitChanges).toHaveBeenCalledTimes(2)); mocks.conversation = makeConversation({ conversation_url: "https://runtime-2.example.test/conversations/2", }); rerender(); await waitFor(() => expect(getGitChanges).toHaveBeenCalledTimes(3)); mocks.conversation = makeConversation({ session_api_key: "session-key-2" }); rerender(); await waitFor(() => expect(getGitChanges).toHaveBeenCalledTimes(4)); mocks.conversation = makeConversation({ workspace: { working_dir: "/workspace/other" }, }); rerender(); await waitFor(() => expect(getGitChanges).toHaveBeenCalledTimes(5)); mocks.conversation = makeConversation({ selected_repository: "OpenHands/another-repository", workspace: null, }); rerender(); await waitFor(() => expect(getGitChanges).toHaveBeenCalledTimes(6)); }); it("refetches on remount even while the cached changes are fresh", async () => { const { queryClient, wrapper } = prepareHook(); const refresh = makeDeferred(); getGitChanges .mockResolvedValueOnce([makeChange("cached.ts")]) .mockReturnValueOnce(refresh.promise); const firstRender = renderHook(() => useUnifiedGetGitChanges(), { wrapper, }); await waitFor(() => expect(getGitChanges).toHaveBeenCalledOnce()); await waitFor(() => expect(firstRender.result.current.data).toEqual([ makeChange("cached.ts"), ]), ); firstRender.unmount(); const secondRender = renderHook(() => useUnifiedGetGitChanges(), { wrapper, }); await waitFor(() => expect(getGitChanges).toHaveBeenCalledTimes(2)); await waitFor(() => expect(secondRender.result.current.isFetching).toBe(true), ); expect(secondRender.result.current.data).toEqual([]); await act(async () => { refresh.resolve([makeChange("refreshed.ts")]); }); await waitFor(() => expect(secondRender.result.current.data).toEqual([ makeChange("refreshed.ts"), ]), ); expect( queryClient.getQueryState([ "file_changes", "conversation-1", "https://runtime.example.test/conversations/conversation-1", "session-key", "/workspace/agent-canvas", ])?.status, ).toBe("success"); }); });