openhands / __tests__ /hooks /query /git-changes-ordering.test.tsx
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
fd2c364 verified
Raw
History Blame Contribute Delete
13.4 kB
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> = {},
): 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<T>() {
const resolver = { current: (_value: T) => {} };
const promise = new Promise<T>((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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
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<GitChange[]>();
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<typeof AgentServerGitService.getGitChanges>
>,
);
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<GitChange[]>();
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");
});
});