import { describe, expect, vi, beforeEach, it, afterEach } from "vitest"; import GitService from "#/api/git-service/git-service.api"; import * as cloudGitService from "#/api/cloud/git-service.api"; import * as activeStore from "#/api/backend-registry/active-store"; const workspaceMocks = vi.hoisted(() => ({ gitChanges: vi.fn(), gitDiff: vi.fn(), resolveWorkingDir: vi.fn(), clientOptions: vi.fn(), mapStatus: vi.fn(), })); vi.mock("#/api/cloud/git-service.api", () => ({ searchCloudRepositories: vi.fn(), getCloudInstallations: vi.fn(), getCloudRepositoryBranches: vi.fn(), })); vi.mock("#/api/backend-registry/active-store", () => ({ getActiveBackend: vi.fn(), })); vi.mock("@openhands/typescript-client/workspace/remote-workspace", () => ({ RemoteWorkspace: vi.fn(function RemoteWorkspaceMock() { return { gitChanges: workspaceMocks.gitChanges, gitDiff: workspaceMocks.gitDiff, }; }), })); vi.mock( "#/api/conversation-service/agent-server-conversation-service.api", () => ({ default: { resolveConversationWorkingDir: workspaceMocks.resolveWorkingDir, }, }), ); vi.mock("#/api/agent-server-client-options", () => ({ getAgentServerClientOptions: workspaceMocks.clientOptions, })); vi.mock("#/utils/git-status-mapper", () => ({ mapAnyGitStatusToClientStatus: workspaceMocks.mapStatus, })); const mockSearchCloudRepositories = vi.mocked( cloudGitService.searchCloudRepositories, ); const mockGetCloudInstallations = vi.mocked( cloudGitService.getCloudInstallations, ); const mockGetCloudRepositoryBranches = vi.mocked( cloudGitService.getCloudRepositoryBranches, ); const mockGetActiveBackend = vi.mocked(activeStore.getActiveBackend); const cloudActive = () => mockGetActiveBackend.mockReturnValue({ backend: { kind: "cloud", id: "test", name: "Test", host: "https://example.com", apiKey: "test-key", }, orgId: "org-1", }); const localActive = () => mockGetActiveBackend.mockReturnValue({ backend: { kind: "local", id: "test", name: "Test", host: "http://localhost", apiKey: "test-key", }, orgId: null, }); describe("GitService", () => { beforeEach(() => { vi.clearAllMocks(); workspaceMocks.resolveWorkingDir.mockResolvedValue("/workspace/project"); workspaceMocks.clientOptions.mockReturnValue({ host: "http://localhost" }); workspaceMocks.mapStatus.mockImplementation((status) => `mapped:${status}`); }); afterEach(() => { vi.clearAllMocks(); }); describe("invalid provider guards", () => { const invalidProviders = [ { value: null, name: "null" }, { value: undefined, name: "undefined" }, { value: "", name: "empty string" }, { value: "undefined", name: '"undefined" string' }, { value: "null", name: '"null" string' }, ]; describe("searchGitRepositories", () => { it.each(invalidProviders)( "should return empty results when provider is $name (cloud mode)", async ({ value }) => { cloudActive(); const result = await GitService.searchGitRepositories( "test query", value as string, ); expect(result).toEqual({ items: [], next_page_id: null }); expect(mockSearchCloudRepositories).not.toHaveBeenCalled(); }, ); }); describe("retrieveUserGitRepositories", () => { it.each(invalidProviders)( "should return empty results when provider is $name", async ({ value }) => { cloudActive(); const result = await GitService.retrieveUserGitRepositories( value as string, ); expect(result).toEqual({ items: [], next_page_id: null }); expect(mockSearchCloudRepositories).not.toHaveBeenCalled(); }, ); }); describe("retrieveInstallationRepositories", () => { it.each(invalidProviders)( "should return empty results when provider is $name", async ({ value }) => { cloudActive(); const result = await GitService.retrieveInstallationRepositories( value as string, 0, ["installation-1"], ); expect(result).toEqual({ items: [], next_page_id: null }); expect(mockSearchCloudRepositories).not.toHaveBeenCalled(); }, ); }); describe("getUserInstallations", () => { it.each(invalidProviders)( "should return empty results when provider is $name", async ({ value }) => { cloudActive(); const result = await GitService.getUserInstallations(value as string); expect(result).toEqual({ items: [], next_page_id: null }); expect(mockGetCloudInstallations).not.toHaveBeenCalled(); }, ); }); describe("getRepositoryBranches", () => { it.each(invalidProviders)( "should return empty results when provider is $name", async ({ value }) => { cloudActive(); const result = await GitService.getRepositoryBranches( "owner/repo", value as string, ); expect(result).toEqual({ items: [], next_page_id: null }); expect(mockGetCloudRepositoryBranches).not.toHaveBeenCalled(); }, ); }); describe("searchRepositoryBranches", () => { it.each(invalidProviders)( "should return empty results when provider is $name", async ({ value }) => { cloudActive(); const result = await GitService.searchRepositoryBranches( "owner/repo", value as string, "main", ); expect(result).toEqual({ items: [], next_page_id: null }); expect(mockGetCloudRepositoryBranches).not.toHaveBeenCalled(); }, ); }); }); describe("valid provider behavior", () => { it("should call cloud API when provider is valid and cloud is active", async () => { cloudActive(); mockSearchCloudRepositories.mockResolvedValue({ items: [ { id: "1", full_name: "owner/repo", git_provider: "github", is_public: true, }, ], next_page_id: null, }); const result = await GitService.searchGitRepositories("test", "github"); expect(mockSearchCloudRepositories).toHaveBeenCalledWith({ provider: "github", query: "test", limit: 100, pageId: undefined, installationId: undefined, }); expect(result.items).toHaveLength(1); }); it("should short-circuit to empty results when provider is valid but local backend is active", async () => { localActive(); const result = await GitService.searchGitRepositories("test", "github"); expect(result).toEqual({ items: [], next_page_id: null }); expect(mockSearchCloudRepositories).not.toHaveBeenCalled(); }); it("routes every supported cloud lookup with normalized parameters", async () => { cloudActive(); const repositories = { items: [], next_page_id: "repos-next" }; const branches = { items: [], next_page_id: "branches-next" }; const installations = { items: [], next_page_id: "installs-next" }; mockSearchCloudRepositories.mockResolvedValue(repositories); mockGetCloudRepositoryBranches.mockResolvedValue(branches); mockGetCloudInstallations.mockResolvedValue(installations); await expect( GitService.searchGitRepositories("", "github", 5, "page", "install"), ).resolves.toBe(repositories); expect(mockSearchCloudRepositories).toHaveBeenLastCalledWith({ provider: "github", query: undefined, limit: 5, pageId: "page", installationId: "install", }); await expect( GitService.retrieveUserGitRepositories("github", "next", 6, "install"), ).resolves.toBe(repositories); expect(mockSearchCloudRepositories).toHaveBeenLastCalledWith({ provider: "github", limit: 6, pageId: "next", installationId: "install", }); await expect( GitService.retrieveInstallationRepositories( "github", 1, ["first", "second"], "next", 7, ), ).resolves.toBe(repositories); expect(mockSearchCloudRepositories).toHaveBeenLastCalledWith({ provider: "github", installationId: "second", limit: 7, pageId: "next", }); await expect( GitService.retrieveInstallationRepositories("github", 5, ["first"]), ).resolves.toEqual({ items: [], next_page_id: null }); await expect( GitService.getRepositoryBranches("owner/repo", "github", "", "next", 8), ).resolves.toBe(branches); expect(mockGetCloudRepositoryBranches).toHaveBeenLastCalledWith({ provider: "github", repository: "owner/repo", query: undefined, pageId: "next", limit: 8, }); await expect( GitService.searchRepositoryBranches( "owner/repo", "github", "feature", "next", 9, ), ).resolves.toBe(branches); expect(mockGetCloudRepositoryBranches).toHaveBeenLastCalledWith({ provider: "github", repository: "owner/repo", query: "feature", pageId: "next", limit: 9, }); await expect( GitService.getUserInstallations("github", "next", 10), ).resolves.toBe(installations); expect(mockGetCloudInstallations).toHaveBeenCalledWith({ provider: "github", pageId: "next", limit: 10, }); }); it("short-circuits every cloud-only lookup for a local backend", async () => { localActive(); await expect( GitService.retrieveUserGitRepositories("github"), ).resolves.toEqual({ items: [], next_page_id: null }); await expect( GitService.retrieveInstallationRepositories("github", 0, ["install"]), ).resolves.toEqual({ items: [], next_page_id: null }); await expect( GitService.getRepositoryBranches("owner/repo", "github"), ).resolves.toEqual({ items: [], next_page_id: null }); await expect( GitService.searchRepositoryBranches("owner/repo", "github", "main"), ).resolves.toEqual({ items: [], next_page_id: null }); await expect(GitService.getUserInstallations("github")).resolves.toEqual({ items: [], next_page_id: null, }); }); it("maps workspace git changes using the SDK default revision", async () => { workspaceMocks.gitChanges.mockResolvedValue([ { path: "src/a.ts", status: "M" }, { path: "src/b.ts", status: 7 }, ]); await expect(GitService.getGitChanges("conversation-1")).resolves.toEqual( [ { path: "src/a.ts", status: "mapped:M" }, { path: "src/b.ts", status: "mapped:7" }, ], ); expect(workspaceMocks.clientOptions).toHaveBeenCalledWith({ workingDir: "/workspace/project", }); expect(workspaceMocks.gitChanges).toHaveBeenCalledWith( "/workspace/project", ); }); it.each([ [ { modified: "new", original: "old" }, { modified: "new", original: "old" }, ], [{}, { modified: "", original: "" }], ])( "normalizes workspace diff content using the SDK default revision", async (diff, expected) => { workspaceMocks.gitDiff.mockResolvedValue(diff); await expect( GitService.getGitChangeDiff("ignored", "src/a.ts"), ).resolves.toEqual(expected); expect(workspaceMocks.gitDiff).toHaveBeenCalledWith("src/a.ts"); }, ); it("initializes fresh provider guards and empty result pages", async () => { vi.resetModules(); try { const freshCloud = await import("#/api/cloud/git-service.api"); const freshActiveStore = await import("#/api/backend-registry/active-store"); const { default: FreshGitService } = await import("#/api/git-service/git-service.api"); const freshSearch = vi.mocked(freshCloud.searchCloudRepositories); const freshBranches = vi.mocked(freshCloud.getCloudRepositoryBranches); const freshInstallations = vi.mocked(freshCloud.getCloudInstallations); vi.mocked(freshActiveStore.getActiveBackend).mockReturnValue({ backend: { kind: "cloud", id: "fresh", name: "Fresh", host: "https://example.com", apiKey: "key", }, orgId: "org-1", }); const repositoryPage = { items: [], next_page_id: "fresh-repo" }; const branchPage = { items: [], next_page_id: "fresh-branch" }; freshSearch.mockResolvedValue(repositoryPage); freshBranches.mockResolvedValue(branchPage); await expect( FreshGitService.searchGitRepositories("query", "github"), ).resolves.toBe(repositoryPage); expect(freshSearch).toHaveBeenCalledWith( expect.objectContaining({ provider: "github" }), ); await expect( FreshGitService.getRepositoryBranches("owner/repo", "github"), ).resolves.toBe(branchPage); expect(freshBranches).toHaveBeenCalledWith({ provider: "github", repository: "owner/repo", query: undefined, pageId: undefined, limit: 30, }); vi.clearAllMocks(); await expect( FreshGitService.searchGitRepositories("query", "undefined"), ).resolves.toEqual({ items: [], next_page_id: null }); await expect( FreshGitService.getRepositoryBranches("owner/repo", "undefined"), ).resolves.toEqual({ items: [], next_page_id: null }); await expect( FreshGitService.getUserInstallations("undefined"), ).resolves.toEqual({ items: [], next_page_id: null }); expect(freshSearch).not.toHaveBeenCalled(); expect(freshBranches).not.toHaveBeenCalled(); expect(freshInstallations).not.toHaveBeenCalled(); } finally { vi.resetModules(); } }); }); });