mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:19:05 +08:00
refactor: use typescript client for cloud transport (#1621)
* Move cloud transport to typescript client * docs: add issue 1648 live evidence * test: align automation cloud import with proxy client Co-authored-by: OpenHands <openhands@all-hands.dev> * fix: handle shared-client HttpError shapes at cloud call sites * chore: Remove PR-only artifacts --------- Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: hieptl <hieptl.developer@gmail.com> Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
OpenHands
hieptl
allhands-bot
parent
e06c8ed86b
commit
2fa0296d2f
@@ -4,7 +4,6 @@ import {
|
||||
ProfilesClient,
|
||||
SettingsClient,
|
||||
} from "@openhands/typescript-client/clients";
|
||||
import axios from "axios";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -13,8 +12,11 @@ import {
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
import {
|
||||
getFetchCall,
|
||||
getJsonBody,
|
||||
mockJsonResponse,
|
||||
} from "./cloud/fetch-test-utils";
|
||||
|
||||
const {
|
||||
mockHttpGet,
|
||||
@@ -44,6 +46,9 @@ const {
|
||||
mockActivateProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
vi.mock("@openhands/typescript-client/clients", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@openhands/typescript-client/clients")
|
||||
@@ -102,6 +107,8 @@ describe("AgentServerConversationService", () => {
|
||||
mockActivateProfile.mockReset();
|
||||
mockSwitchProfile.mockReset();
|
||||
mockSwitchLLM.mockReset();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
vi.mocked(ConversationClient).mockClear();
|
||||
vi.mocked(FileClient).mockClear();
|
||||
vi.mocked(ProfilesClient).mockClear();
|
||||
@@ -419,6 +426,8 @@ describe("AgentServerConversationService", () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("hits the local /api/file/download-trajectory endpoint with responseType blob when active backend is local", async () => {
|
||||
@@ -886,17 +895,20 @@ describe("AgentServerConversationService", () => {
|
||||
};
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: { success: true } });
|
||||
fetchMock.mockResolvedValueOnce(mockJsonResponse({ success: true }));
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
|
||||
await AgentServerConversationService.switchProfile("conv-1", "haiku");
|
||||
|
||||
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(cfg).toMatchObject({
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(
|
||||
"https://app.all-hands.dev/api/v1/app-conversations/conv-1/switch_profile",
|
||||
);
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
url: "https://app.all-hands.dev/api/v1/app-conversations/conv-1/switch_profile",
|
||||
data: { profile_name: "haiku" },
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect(getJsonBody(init)).toEqual({ profile_name: "haiku" });
|
||||
// Cloud resolves the swap server-side: no client-side encrypted profile
|
||||
// fetch and no direct switch_llm call.
|
||||
expect(mockGetProfile).not.toHaveBeenCalled();
|
||||
@@ -918,18 +930,21 @@ describe("AgentServerConversationService", () => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("forwards parent_conversation_id, agent_type, and sandbox_id to the cloud createConversation payload", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
id: "task-1",
|
||||
status: "WORKING",
|
||||
app_conversation_id: null,
|
||||
@@ -937,8 +952,8 @@ describe("AgentServerConversationService", () => {
|
||||
request: {},
|
||||
created_at: "2024-01-01",
|
||||
updated_at: "2024-01-01",
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
await AgentServerConversationService.createConversation(
|
||||
@@ -954,13 +969,13 @@ describe("AgentServerConversationService", () => {
|
||||
);
|
||||
|
||||
// Assert
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/app-conversations`,
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations`);
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect((config as { data: Record<string, unknown> }).data).toMatchObject({
|
||||
expect(getJsonBody(init)).toMatchObject({
|
||||
parent_conversation_id: "parent-conv-1",
|
||||
agent_type: "plan",
|
||||
sandbox_id: "sandbox-9",
|
||||
@@ -969,7 +984,12 @@ describe("AgentServerConversationService", () => {
|
||||
|
||||
it("routes readConversationFile to the cloud file endpoint with the file_path query param", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: "# PLAN content" });
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response("# PLAN content", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain" },
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
const content =
|
||||
@@ -979,12 +999,12 @@ describe("AgentServerConversationService", () => {
|
||||
|
||||
// Assert
|
||||
expect(content).toBe("# PLAN content");
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect((config as { url: string }).url).toBe(
|
||||
expect(url).toBe(
|
||||
`${cloudBackend.host}/api/v1/app-conversations/conv-cloud-1/file?file_path=%2Fworkspace%2Fproject%2F.agents_tmp%2FPLAN.md`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -7,8 +6,11 @@ import {
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
import {
|
||||
getFetchCall,
|
||||
getJsonBody,
|
||||
mockJsonResponse,
|
||||
} from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
@@ -18,23 +20,29 @@ const cloudBackend: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("AgentServerConversationService cloud branch", () => {
|
||||
it("createConversation POSTs the cloud payload directly and returns a WORKING task", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: {
|
||||
fetchMock.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
id: "task-123",
|
||||
created_by_user_id: null,
|
||||
status: "WORKING",
|
||||
@@ -44,8 +52,8 @@ describe("AgentServerConversationService cloud branch", () => {
|
||||
request: {},
|
||||
created_at: "2026-05-06T00:00:00Z",
|
||||
updated_at: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await AgentServerConversationService.createConversation(
|
||||
"fix the bug",
|
||||
@@ -59,15 +67,14 @@ describe("AgentServerConversationService cloud branch", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledOnce();
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/app-conversations`,
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations`);
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
const requestBody = (config as { data: Record<string, unknown> }).data;
|
||||
const requestBody = getJsonBody(init);
|
||||
|
||||
// cloud payload shape — flat fields, NO encrypted-settings round-trip.
|
||||
expect(requestBody.selected_repository).toBe("user/repo");
|
||||
@@ -89,8 +96,8 @@ describe("AgentServerConversationService cloud branch", () => {
|
||||
});
|
||||
|
||||
it("getStartTask polls /api/v1/app-conversations/start-tasks?ids= directly", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: [
|
||||
fetchMock.mockResolvedValue(
|
||||
mockJsonResponse([
|
||||
{
|
||||
id: "task-123",
|
||||
created_by_user_id: null,
|
||||
@@ -102,15 +109,17 @@ describe("AgentServerConversationService cloud branch", () => {
|
||||
created_at: "2026-05-06T00:00:00Z",
|
||||
updated_at: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
]),
|
||||
);
|
||||
|
||||
const result =
|
||||
await AgentServerConversationService.getStartTask("task-123");
|
||||
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/app-conversations/start-tasks?ids=task-123`,
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(
|
||||
`${cloudBackend.host}/api/v1/app-conversations/start-tasks?ids=task-123`,
|
||||
);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -7,8 +6,7 @@ import {
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
@@ -18,30 +16,34 @@ const cloudBackend: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(mockJsonResponse({ success: true }));
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("AgentServerConversationService.deleteConversation cloud branch", () => {
|
||||
it("calls the cloud DELETE app-conversations endpoint directly", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: { success: true } });
|
||||
|
||||
await AgentServerConversationService.deleteConversation("conv-abc");
|
||||
|
||||
expect(axios.request).toHaveBeenCalledOnce();
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/app-conversations/conv-abc`,
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`);
|
||||
expect(init).toMatchObject({
|
||||
method: "DELETE",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -7,8 +6,11 @@ import {
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
import {
|
||||
getFetchCall,
|
||||
mockBlobResponse,
|
||||
mockJsonResponse,
|
||||
} from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
@@ -18,36 +20,44 @@ const cloudBackend: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(mockJsonResponse({}));
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("AgentServerConversationService.downloadConversation cloud branch", () => {
|
||||
it("calls the cloud download endpoint directly with responseType blob and returns the Blob", async () => {
|
||||
const zipBlob = new Blob(["zip-bytes"], { type: "application/zip" });
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: zipBlob });
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockBlobResponse("zip-bytes", "application/zip"),
|
||||
);
|
||||
|
||||
const result =
|
||||
await AgentServerConversationService.downloadConversation("conv-abc");
|
||||
|
||||
expect(axios.request).toHaveBeenCalledOnce();
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/app-conversations/conv-abc/download`,
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(
|
||||
`${cloudBackend.host}/api/v1/app-conversations/conv-abc/download`,
|
||||
);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
responseType: "blob",
|
||||
});
|
||||
expect(result).toBe(zipBlob);
|
||||
await expect(result.text()).resolves.toBe("zip-bytes");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -10,8 +9,7 @@ import AgentServerConversationService from "#/api/conversation-service/agent-ser
|
||||
import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
|
||||
import { pauseConversation } from "#/hooks/mutation/conversation-mutation-utils";
|
||||
import { ExecutionStatus } from "#/types/agent-server/core/base/common";
|
||||
|
||||
vi.mock("axios");
|
||||
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
@@ -44,17 +42,24 @@ const buildConversation = (
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(mockJsonResponse({ success: true }));
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -64,14 +69,13 @@ describe("pauseConversation cloud branch", () => {
|
||||
AgentServerConversationService,
|
||||
"batchGetAppConversations",
|
||||
).mockResolvedValue([buildConversation({ sandbox_id: "sandbox-xyz" })]);
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: { success: true } });
|
||||
|
||||
await pauseConversation("conv-abc");
|
||||
|
||||
expect(axios.request).toHaveBeenCalledOnce();
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/sandboxes/sandbox-xyz/pause`,
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${cloudBackend.host}/api/v1/sandboxes/sandbox-xyz/pause`);
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
@@ -84,6 +88,6 @@ describe("pauseConversation cloud branch", () => {
|
||||
).mockResolvedValue([buildConversation({ sandbox_id: null })]);
|
||||
|
||||
await expect(pauseConversation("conv-abc")).rejects.toThrow(/sandbox_id/);
|
||||
expect(axios.request).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -7,8 +6,11 @@ import {
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
import {
|
||||
getFetchCall,
|
||||
getJsonBody,
|
||||
mockJsonResponse,
|
||||
} from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
@@ -18,38 +20,44 @@ const cloudBackend: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(
|
||||
mockJsonResponse({ id: "conv-abc", public: true }),
|
||||
);
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("AgentServerConversationService.updateConversationPublicFlag", () => {
|
||||
it("PATCHes /api/v1/app-conversations/{id} directly on a cloud backend", async () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: { id: "conv-abc", public: true },
|
||||
});
|
||||
|
||||
await AgentServerConversationService.updateConversationPublicFlag(
|
||||
"conv-abc",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledOnce();
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/app-conversations/conv-abc`,
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`);
|
||||
expect(init).toMatchObject({
|
||||
method: "PATCH",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
data: { public: true },
|
||||
});
|
||||
expect(getJsonBody(init)).toEqual({ public: true });
|
||||
});
|
||||
|
||||
it("rejects without calling the cloud API when the active backend is local", async () => {
|
||||
@@ -60,6 +68,6 @@ describe("AgentServerConversationService.updateConversationPublicFlag", () => {
|
||||
true,
|
||||
),
|
||||
).rejects.toThrow(/cloud backend/);
|
||||
expect(axios.request).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -7,8 +6,11 @@ import {
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
import {
|
||||
getFetchCall,
|
||||
getJsonBody,
|
||||
mockJsonResponse,
|
||||
} from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
@@ -48,9 +50,12 @@ const runtimeResponse = {
|
||||
},
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -61,12 +66,13 @@ describe("AgentServerConversationService.getRuntimeConversation", () => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("routes through /api/cloud-proxy targeting the conversation runtime host", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: runtimeResponse });
|
||||
fetchMock.mockResolvedValue(mockJsonResponse(runtimeResponse));
|
||||
const conversationUrl =
|
||||
"http://abc123.runtime.all-hands.dev/api/conversations/conv-abc";
|
||||
|
||||
@@ -79,8 +85,9 @@ describe("AgentServerConversationService.getRuntimeConversation", () => {
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
const body = getJsonBody(init);
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: "http://abc123.runtime.all-hands.dev",
|
||||
@@ -97,20 +104,13 @@ describe("AgentServerConversationService.getRuntimeConversation", () => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("targets the conversation_url host (not the active backend host) and forwards X-Session-API-Key", async () => {
|
||||
// Arrange
|
||||
const fetchMock = vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify(runtimeResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchMock.mockResolvedValue(mockJsonResponse(runtimeResponse));
|
||||
const conversationUrl =
|
||||
"http://192.168.1.42:8888/api/conversations/conv-abc";
|
||||
|
||||
@@ -123,15 +123,11 @@ describe("AgentServerConversationService.getRuntimeConversation", () => {
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const call = fetchMock.mock.calls[0] as unknown as [
|
||||
RequestInfo,
|
||||
RequestInit,
|
||||
];
|
||||
expect(String(call[0])).toContain("192.168.1.42:8888");
|
||||
expect(String(call[0])).not.toContain(localBackend.host);
|
||||
expect(call[1]?.headers).toMatchObject({
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toContain("192.168.1.42:8888");
|
||||
expect(url).not.toContain(localBackend.host);
|
||||
expect(init.headers).toMatchObject({
|
||||
"X-Session-API-Key": "session-xyz",
|
||||
});
|
||||
expect(result.id).toBe("conv-abc");
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
type FetchMock = ReturnType<typeof vi.fn>;
|
||||
|
||||
export function mockJsonResponse(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
statusText: status === 200 ? "OK" : "Error",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
export function mockBlobResponse(
|
||||
body: BodyInit,
|
||||
contentType: string,
|
||||
): Response {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
}
|
||||
|
||||
export function getFetchCall(
|
||||
fetchMock: FetchMock,
|
||||
index = 0,
|
||||
): [string, RequestInit] {
|
||||
const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit];
|
||||
return [url, init ?? {}];
|
||||
}
|
||||
|
||||
export function getJsonBody(init: RequestInit): Record<string, unknown> {
|
||||
return JSON.parse(String(init.body ?? "{}")) as Record<string, unknown>;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -7,8 +6,7 @@ import {
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { getCloudRepositoryBranches } from "#/api/cloud/git-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
@@ -18,28 +16,29 @@ const cloudBackend: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const emptyBranchPage = {
|
||||
data: { items: [], next_page_id: null },
|
||||
};
|
||||
const emptyBranchPage = { items: [], next_page_id: null };
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(mockJsonResponse(emptyBranchPage));
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("getCloudRepositoryBranches", () => {
|
||||
it("includes an empty query parameter when listing all branches so the upstream schema is satisfied", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.request).mockResolvedValueOnce(emptyBranchPage);
|
||||
|
||||
// Act
|
||||
await getCloudRepositoryBranches({
|
||||
provider: "github",
|
||||
@@ -47,15 +46,15 @@ describe("getCloudRepositoryBranches", () => {
|
||||
});
|
||||
|
||||
// Assert
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const url = (config as { url: string }).url;
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect(url).toMatch(/[?&]query=(&|$)/);
|
||||
});
|
||||
|
||||
it("forwards a non-empty query parameter when searching branches", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.request).mockResolvedValueOnce(emptyBranchPage);
|
||||
|
||||
// Act
|
||||
await getCloudRepositoryBranches({
|
||||
provider: "github",
|
||||
@@ -64,8 +63,7 @@ describe("getCloudRepositoryBranches", () => {
|
||||
});
|
||||
|
||||
// Assert
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const url = (config as { url: string }).url;
|
||||
const [url] = getFetchCall(fetchMock);
|
||||
expect(url).toContain("query=feature%2Flogin");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -7,8 +6,7 @@ import {
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { getCloudOrganizationMe } from "#/api/cloud/organization-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
@@ -18,37 +16,43 @@ const cloudBackend: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("cloud organization /me", () => {
|
||||
it("calls /api/organizations/{orgId}/me directly and returns user_id", async () => {
|
||||
const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: {
|
||||
fetchMock.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
org_id: orgId,
|
||||
user_id: orgId,
|
||||
email: "hieptl.developer@gmail.com",
|
||||
role: "owner",
|
||||
permissions: ["view_org_settings", "edit_org_settings"],
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await getCloudOrganizationMe(orgId);
|
||||
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/organizations/${orgId}/me`,
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${cloudBackend.host}/api/organizations/${orgId}/me`);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
@@ -62,14 +66,14 @@ describe("cloud organization /me", () => {
|
||||
|
||||
it("returns null permissions when the app-server omits them (older version)", async () => {
|
||||
const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: {
|
||||
fetchMock.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
org_id: orgId,
|
||||
user_id: orgId,
|
||||
email: "x@example.com",
|
||||
role: "member",
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await getCloudOrganizationMe(orgId);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HttpError } from "@openhands/typescript-client";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setRegisteredBackends,
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
} from "#/api/cloud/organization-service.api";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
@@ -20,35 +18,48 @@ const cloudBackend: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function mockJsonResponse(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
statusText: status === 200 ? "OK" : "Error",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([]);
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("cloud organization-service", () => {
|
||||
it("getCloudOrganizations calls the cloud API directly and returns normalized data", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
items: [{ id: "org-1", name: "Personal" }],
|
||||
current_org_id: "org-1",
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await getCloudOrganizations(cloudBackend);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledOnce();
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/organizations`,
|
||||
expect(url).toBe(`${cloudBackend.host}/api/organizations`);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
@@ -60,21 +71,21 @@ describe("cloud organization-service", () => {
|
||||
});
|
||||
|
||||
it("getCurrentCloudApiKey hits /api/keys/current and returns the bound orgId", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
id: "key-1",
|
||||
name: "k",
|
||||
org_id: "org-bound",
|
||||
user_id: "user-1",
|
||||
auth_type: "bearer",
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await getCurrentCloudApiKey(cloudBackend);
|
||||
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/keys/current`,
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe(`${cloudBackend.host}/api/keys/current`);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
@@ -82,11 +93,7 @@ describe("cloud organization-service", () => {
|
||||
});
|
||||
|
||||
it("getCurrentCloudApiKey treats an upstream 400 as a legacy key (no binding)", async () => {
|
||||
const error = Object.assign(new Error("Bad Request"), {
|
||||
response: { status: 400 },
|
||||
});
|
||||
vi.mocked(axios.isAxiosError).mockReturnValueOnce(true);
|
||||
vi.mocked(axios.request).mockRejectedValueOnce(error);
|
||||
fetchMock.mockResolvedValueOnce(mockJsonResponse({ detail: "bad" }, 400));
|
||||
|
||||
const result = await getCurrentCloudApiKey(cloudBackend);
|
||||
|
||||
@@ -94,12 +101,12 @@ describe("cloud organization-service", () => {
|
||||
});
|
||||
|
||||
it("getCurrentCloudApiKey rethrows non-400 upstream errors (e.g. revoked key)", async () => {
|
||||
const error = Object.assign(new Error("Unauthorized"), {
|
||||
response: { status: 401 },
|
||||
});
|
||||
vi.mocked(axios.isAxiosError).mockReturnValueOnce(true);
|
||||
vi.mocked(axios.request).mockRejectedValueOnce(error);
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({ detail: "unauthorized" }, 401),
|
||||
);
|
||||
|
||||
await expect(getCurrentCloudApiKey(cloudBackend)).rejects.toBe(error);
|
||||
await expect(getCurrentCloudApiKey(cloudBackend)).rejects.toBeInstanceOf(
|
||||
HttpError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -9,8 +8,11 @@ import type { Backend } from "#/api/backend-registry/types";
|
||||
import ProfilesService, {
|
||||
type SaveProfileRequest,
|
||||
} from "#/api/profiles-service/profiles-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
import {
|
||||
getFetchCall,
|
||||
getJsonBody,
|
||||
mockJsonResponse,
|
||||
} from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
@@ -23,17 +25,23 @@ const cloudBackend: Backend = {
|
||||
const ORG_ID = "org-1";
|
||||
const ORG_BASE = `https://app.all-hands.dev/api/organizations/${ORG_ID}/profiles`;
|
||||
const SETTINGS_BASE = "https://app.all-hands.dev/api/v1/settings/profiles";
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(mockJsonResponse({}));
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// With an org bound, profile CRUD goes through the org-gated routes so the
|
||||
@@ -44,37 +52,40 @@ describe("ProfilesService against a cloud org (gated org routes)", () => {
|
||||
});
|
||||
|
||||
it("lists profiles via GET /api/organizations/{orgId}/profiles", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({
|
||||
data: {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
profiles: [
|
||||
{ name: "gpt", model: "gpt-4o", base_url: null, api_key_set: true },
|
||||
],
|
||||
active_profile: "gpt",
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await ProfilesService.listProfiles();
|
||||
|
||||
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(cfg).toMatchObject({
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(ORG_BASE);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
url: ORG_BASE,
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect(res.active_profile).toBe("gpt");
|
||||
});
|
||||
|
||||
it("fetches a profile and maps the org `llm` onto `config`", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({
|
||||
data: { name: "my profile", llm: { model: "gpt-4o", api_key: null } },
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
name: "my profile",
|
||||
llm: { model: "gpt-4o", api_key: null },
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await ProfilesService.getProfile("my profile");
|
||||
|
||||
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(cfg).toMatchObject({
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${ORG_BASE}/my%20profile`);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
url: `${ORG_BASE}/my%20profile`,
|
||||
});
|
||||
expect(res).toEqual({
|
||||
name: "my profile",
|
||||
@@ -84,64 +95,68 @@ describe("ProfilesService against a cloud org (gated org routes)", () => {
|
||||
});
|
||||
|
||||
it("saves a profile via POST .../{name} forwarding the request body", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({
|
||||
data: { name: "gpt", message: "Profile 'gpt' saved" },
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({ name: "gpt", message: "Profile 'gpt' saved" }),
|
||||
);
|
||||
|
||||
await ProfilesService.saveProfile("gpt", {
|
||||
llm: { model: "gpt-4o" } as SaveProfileRequest["llm"],
|
||||
include_secrets: true,
|
||||
});
|
||||
|
||||
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(cfg).toMatchObject({
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${ORG_BASE}/gpt`);
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
url: `${ORG_BASE}/gpt`,
|
||||
data: { llm: { model: "gpt-4o" }, include_secrets: true },
|
||||
});
|
||||
expect(getJsonBody(init)).toEqual({
|
||||
llm: { model: "gpt-4o" },
|
||||
include_secrets: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a profile via DELETE .../{name}", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({
|
||||
data: { name: "gpt", message: "Profile 'gpt' deleted" },
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({ name: "gpt", message: "Profile 'gpt' deleted" }),
|
||||
);
|
||||
|
||||
await ProfilesService.deleteProfile("gpt");
|
||||
|
||||
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(cfg).toMatchObject({ method: "DELETE", url: `${ORG_BASE}/gpt` });
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${ORG_BASE}/gpt`);
|
||||
expect(init).toMatchObject({ method: "DELETE" });
|
||||
});
|
||||
|
||||
it("renames a profile via POST .../{name}/rename with new_name", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({
|
||||
data: { name: "new", message: "renamed" },
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({ name: "new", message: "renamed" }),
|
||||
);
|
||||
|
||||
await ProfilesService.renameProfile("old", "new");
|
||||
|
||||
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(cfg).toMatchObject({
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${ORG_BASE}/old/rename`);
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
url: `${ORG_BASE}/old/rename`,
|
||||
data: { new_name: "new" },
|
||||
});
|
||||
expect(getJsonBody(init)).toEqual({ new_name: "new" });
|
||||
});
|
||||
|
||||
it("activates a profile and maps the org `llm` onto `llm_applied`", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({
|
||||
data: {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
name: "gpt",
|
||||
message: "Switched to profile 'gpt'",
|
||||
llm: { model: "gpt-4o" },
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await ProfilesService.activateProfile("gpt");
|
||||
|
||||
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(cfg).toMatchObject({
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${ORG_BASE}/gpt/activate`);
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
url: `${ORG_BASE}/gpt/activate`,
|
||||
});
|
||||
expect(res).toEqual({
|
||||
name: "gpt",
|
||||
@@ -159,41 +174,43 @@ describe("ProfilesService on a cloud backend with no org (fallback)", () => {
|
||||
});
|
||||
|
||||
it("lists via the per-user settings route", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({
|
||||
data: { profiles: [], active_profile: null },
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({ profiles: [], active_profile: null }),
|
||||
);
|
||||
|
||||
await ProfilesService.listProfiles();
|
||||
|
||||
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(cfg).toMatchObject({ method: "GET", url: SETTINGS_BASE });
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(SETTINGS_BASE);
|
||||
expect(init).toMatchObject({ method: "GET" });
|
||||
});
|
||||
|
||||
it("saves via the per-user settings route", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({
|
||||
data: { name: "gpt", message: "saved" },
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({ name: "gpt", message: "saved" }),
|
||||
);
|
||||
|
||||
await ProfilesService.saveProfile("gpt", {
|
||||
llm: { model: "gpt-4o" } as SaveProfileRequest["llm"],
|
||||
include_secrets: true,
|
||||
});
|
||||
|
||||
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(cfg).toMatchObject({ method: "POST", url: `${SETTINGS_BASE}/gpt` });
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${SETTINGS_BASE}/gpt`);
|
||||
expect(init).toMatchObject({ method: "POST" });
|
||||
});
|
||||
|
||||
it("activates via the per-user settings route and maps `model`", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({
|
||||
data: { name: "gpt", message: "ok", model: "gpt-4o" },
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({ name: "gpt", message: "ok", model: "gpt-4o" }),
|
||||
);
|
||||
|
||||
const res = await ProfilesService.activateProfile("gpt");
|
||||
|
||||
const [cfg] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(cfg).toMatchObject({
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${SETTINGS_BASE}/gpt/activate`);
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
url: `${SETTINGS_BASE}/gpt/activate`,
|
||||
});
|
||||
expect(res.llm_applied).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -8,8 +7,6 @@ import {
|
||||
import { callCloudProxy } from "#/api/cloud/proxy";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
const cloudPersonal: Backend = {
|
||||
id: "cloud-personal",
|
||||
name: "Production - Personal",
|
||||
@@ -26,20 +23,29 @@ const cloudAcme: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function mockJsonResponse(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: {} });
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(mockJsonResponse({}));
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
vi.mocked(axios.post).mockReset();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("callCloudProxy X-Org-Id injection", () => {
|
||||
@@ -63,13 +69,13 @@ describe("callCloudProxy X-Org-Id injection", () => {
|
||||
// Assert — the request carries the X-Org-Id of the active selection so the
|
||||
// cloud backend can scope this request to the user's locally-chosen org
|
||||
// without depending on user.current_org_id.
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudPersonal.host}/api/v1/app-conversations/search`,
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe(`${cloudPersonal.host}/api/v1/app-conversations/search`);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
});
|
||||
expect(
|
||||
(config as { headers: Record<string, string> }).headers["X-Org-Id"],
|
||||
(init as { headers: Record<string, string> }).headers["X-Org-Id"],
|
||||
).toBe("org-personal-uuid");
|
||||
});
|
||||
|
||||
@@ -92,9 +98,9 @@ describe("callCloudProxy X-Org-Id injection", () => {
|
||||
});
|
||||
|
||||
// Assert
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(
|
||||
(config as { headers: Record<string, string> }).headers,
|
||||
(init as { headers: Record<string, string> }).headers,
|
||||
).not.toHaveProperty("X-Org-Id");
|
||||
});
|
||||
});
|
||||
@@ -107,7 +113,7 @@ describe("callCloudProxy automation direct routing", () => {
|
||||
setRegisteredBackends([cloudPersonal]);
|
||||
setActiveSelection({ backendId: cloudPersonal.id, orgId: null });
|
||||
const page = { automations: [], total: 0 };
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: page });
|
||||
fetchMock.mockResolvedValueOnce(mockJsonResponse(page));
|
||||
|
||||
// Act
|
||||
const result = await callCloudProxy({
|
||||
@@ -119,14 +125,15 @@ describe("callCloudProxy automation direct routing", () => {
|
||||
// Assert — the browser calls the automation API on the cloud host
|
||||
// directly, authenticated by the backend's API key, and no envelope
|
||||
// POST reaches /api/cloud-proxy.
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudPersonal.host}/api/automation/v1?limit=50&offset=0`,
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe(
|
||||
`${cloudPersonal.host}/api/automation/v1?limit=50&offset=0`,
|
||||
);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
});
|
||||
expect(
|
||||
(config as { headers: Record<string, string> }).headers.Authorization,
|
||||
(init as { headers: Record<string, string> }).headers.Authorization,
|
||||
).toBe(`Bearer ${cloudPersonal.apiKey}`);
|
||||
expect(result).toEqual(page);
|
||||
});
|
||||
@@ -148,11 +155,9 @@ describe("callCloudProxy automation direct routing", () => {
|
||||
});
|
||||
|
||||
// Assert
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
responseType: "blob",
|
||||
timeout: 5000,
|
||||
});
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe(`${cloudPersonal.host}/api/automation/v1/auto-1/tarball`);
|
||||
expect(init).toMatchObject({ method: "GET" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,7 +170,7 @@ describe("callCloudProxy hostOverride routing", () => {
|
||||
// local GUI origin.
|
||||
setRegisteredBackends([cloudPersonal]);
|
||||
setActiveSelection({ backendId: cloudPersonal.id, orgId: null });
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: { items: [] } });
|
||||
fetchMock.mockResolvedValueOnce(mockJsonResponse({ items: [] }));
|
||||
|
||||
// Act
|
||||
const result = await callCloudProxy({
|
||||
@@ -178,9 +183,9 @@ describe("callCloudProxy hostOverride routing", () => {
|
||||
// Assert — the browser only makes a same-origin POST to the bundled
|
||||
// agent-server's proxy endpoint carrying the upstream call as an
|
||||
// envelope, and the upstream payload is unwrapped for the caller.
|
||||
expect(axios.request).not.toHaveBeenCalled();
|
||||
const [url, envelope] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
const envelope = JSON.parse((init as { body: string }).body);
|
||||
expect(envelope).toMatchObject({
|
||||
host: runtimeHost,
|
||||
method: "GET",
|
||||
@@ -208,7 +213,8 @@ describe("callCloudProxy hostOverride routing", () => {
|
||||
});
|
||||
|
||||
// Assert
|
||||
const [, envelope] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
const envelope = JSON.parse((init as { body: string }).body);
|
||||
expect(
|
||||
(envelope as { headers: Record<string, string> }).headers,
|
||||
).toMatchObject({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -7,8 +6,7 @@ import {
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import { batchGetCloudSandboxes } from "#/api/cloud/sandbox-service.api";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
vi.mock("axios");
|
||||
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "cloud-prod",
|
||||
@@ -18,19 +16,24 @@ const cloudBackend: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: [] });
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(mockJsonResponse([]));
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("batchGetCloudSandboxes", () => {
|
||||
@@ -44,12 +47,12 @@ describe("batchGetCloudSandboxes", () => {
|
||||
// Act
|
||||
await batchGetCloudSandboxes(ids);
|
||||
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect((config as { url: string }).url).toBe(
|
||||
expect(url).toBe(
|
||||
`${cloudBackend.host}/api/v1/sandboxes?id=sandbox-a&id=sandbox-b`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -7,8 +6,11 @@ import {
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
|
||||
vi.mock("axios");
|
||||
import {
|
||||
getFetchCall,
|
||||
getJsonBody,
|
||||
mockJsonResponse,
|
||||
} from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
@@ -18,105 +20,131 @@ const cloudBackend: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(mockJsonResponse({}));
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("SecretsService against cloud backend", () => {
|
||||
it("paginates getSecrets directly and returns the merged list", async () => {
|
||||
vi.mocked(axios.request)
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
items: [
|
||||
{ name: "ALPHA", description: "first" },
|
||||
{ name: "BETA", description: "second" },
|
||||
],
|
||||
next_page_id: "BETA",
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
items: [{ name: "GAMMA", description: "third" }],
|
||||
next_page_id: null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const secrets = await SecretsService.getSecrets();
|
||||
|
||||
expect(vi.mocked(axios.request)).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [firstConfig] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(firstConfig).toMatchObject({
|
||||
const [firstUrl, firstInit] = getFetchCall(fetchMock, 0);
|
||||
expect(firstInit).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect((firstConfig as { url: string }).url).toMatch(
|
||||
expect(firstUrl).toMatch(
|
||||
/^https:\/\/app\.all-hands\.dev\/api\/v1\/secrets\/search\?/,
|
||||
);
|
||||
expect((firstConfig as { url: string }).url).not.toContain("page_id=");
|
||||
expect(firstUrl).not.toContain("page_id=");
|
||||
|
||||
const [secondConfig] = vi.mocked(axios.request).mock.calls[1]!;
|
||||
expect((secondConfig as { url: string }).url).toContain("page_id=BETA");
|
||||
const [secondUrl] = getFetchCall(fetchMock, 1);
|
||||
expect(secondUrl).toContain("page_id=BETA");
|
||||
|
||||
expect(secrets.map((s) => s.name)).toEqual(["ALPHA", "BETA", "GAMMA"]);
|
||||
});
|
||||
|
||||
it("creates a secret via direct POST /api/v1/secrets", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({ data: {} });
|
||||
|
||||
await SecretsService.createSecret(
|
||||
"OPENAI_API_KEY",
|
||||
"sk-test",
|
||||
"OpenAI key",
|
||||
);
|
||||
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/secrets`,
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${cloudBackend.host}/api/v1/secrets`);
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
data: {
|
||||
name: "OPENAI_API_KEY",
|
||||
value: "sk-test",
|
||||
description: "OpenAI key",
|
||||
},
|
||||
});
|
||||
expect(getJsonBody(init)).toEqual({
|
||||
name: "OPENAI_API_KEY",
|
||||
value: "sk-test",
|
||||
description: "OpenAI key",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates a secret via PUT /api/v1/secrets/{id} with name + description only", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({ data: {} });
|
||||
|
||||
// The form/hook calls updateSecret(secretToEdit, newName, description).
|
||||
await SecretsService.updateSecret("OLD_NAME", "NEW_NAME", "renamed");
|
||||
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/secrets/OLD_NAME`,
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${cloudBackend.host}/api/v1/secrets/OLD_NAME`);
|
||||
expect(init).toMatchObject({
|
||||
method: "PUT",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
data: { name: "NEW_NAME", description: "renamed" },
|
||||
});
|
||||
expect(getJsonBody(init)).toEqual({
|
||||
name: "NEW_NAME",
|
||||
description: "renamed",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a secret via direct DELETE /api/v1/secrets/{id}", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({ data: {} });
|
||||
|
||||
await SecretsService.deleteSecret("token with space");
|
||||
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/secrets/token%20with%20space`,
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(
|
||||
`${cloudBackend.host}/api/v1/secrets/token%20with%20space`,
|
||||
);
|
||||
expect(init).toMatchObject({
|
||||
method: "DELETE",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a delete 404 as success (secret already gone)", async () => {
|
||||
// Fresh Response per attempt: the retry helper re-fetches and a
|
||||
// Response body can only be consumed once. Fake timers skip the
|
||||
// retry backoff sleeps.
|
||||
fetchMock.mockImplementation(() =>
|
||||
Promise.resolve(mockJsonResponse({ detail: "Secret not found" }, 404)),
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const assertion = expect(
|
||||
SecretsService.deleteSecret("ALREADY_GONE"),
|
||||
).resolves.toBeUndefined();
|
||||
await vi.runAllTimersAsync();
|
||||
await assertion;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -11,8 +10,11 @@ import {
|
||||
saveCloudSettings,
|
||||
} from "#/api/cloud/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
import {
|
||||
getFetchCall,
|
||||
getJsonBody,
|
||||
mockJsonResponse,
|
||||
} from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
@@ -22,23 +24,30 @@ const cloudBackend: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(mockJsonResponse({}));
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("cloud settings", () => {
|
||||
it("fetchCloudSettings preserves provider_tokens_set so the repo chain can fire", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
llm_model: "anthropic/claude-3-5-sonnet",
|
||||
llm_base_url: "https://api.anthropic.com",
|
||||
llm_api_key_set: true,
|
||||
@@ -47,14 +56,14 @@ describe("cloud settings", () => {
|
||||
security_analyzer: "llm",
|
||||
max_iterations: 30,
|
||||
provider_tokens_set: { github: "***" },
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchCloudSettings();
|
||||
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/settings`,
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
@@ -80,8 +89,6 @@ describe("cloud settings", () => {
|
||||
});
|
||||
|
||||
it("saveCloudSettings forwards diffs verbatim and omits the legacy keys the cloud rejects", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
|
||||
const agentDiff = {
|
||||
llm: { model: "openai/gpt-4o", base_url: "https://api.openai.com" },
|
||||
agent: "CodeActAgent",
|
||||
@@ -93,13 +100,13 @@ describe("cloud settings", () => {
|
||||
conversation_settings_diff: conversationDiff,
|
||||
});
|
||||
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/settings`,
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
const requestBody = (config as { data: Record<string, unknown> }).data;
|
||||
const requestBody = getJsonBody(init);
|
||||
expect(requestBody).toEqual({
|
||||
agent_settings_diff: agentDiff,
|
||||
conversation_settings_diff: conversationDiff,
|
||||
@@ -109,9 +116,6 @@ describe("cloud settings", () => {
|
||||
});
|
||||
|
||||
it("SettingsService.saveSettings forwards disabled_skills to cloud when active backend is cloud", async () => {
|
||||
// Arrange: cloud backend already active via beforeEach; mock cloud response.
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
|
||||
// Act: save a skills-only update — previously this short-circuited and
|
||||
// sent nothing at all, leaving the toggle un-persisted.
|
||||
await SettingsService.saveSettings({
|
||||
@@ -120,21 +124,19 @@ describe("cloud settings", () => {
|
||||
|
||||
// Assert: a single POST /api/v1/settings reached the wire with
|
||||
// disabled_skills as a top-level field.
|
||||
expect(vi.mocked(axios.request)).toHaveBeenCalledTimes(1);
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/settings`,
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect((config as { data: Record<string, unknown> }).data).toEqual({
|
||||
expect(getJsonBody(init)).toEqual({
|
||||
disabled_skills: ["SSH Microagent"],
|
||||
});
|
||||
});
|
||||
|
||||
it("saveCloudSettings omits an empty conversation_settings_diff (LLM-only save)", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
|
||||
await saveCloudSettings({
|
||||
agent_settings_diff: {
|
||||
llm: { model: "anthropic/claude-sonnet-4-20250514" },
|
||||
@@ -142,8 +144,8 @@ describe("cloud settings", () => {
|
||||
conversation_settings_diff: {},
|
||||
});
|
||||
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const requestBody = (config as { data: Record<string, unknown> }).data;
|
||||
const [, init] = getFetchCall(fetchMock);
|
||||
const requestBody = getJsonBody(init);
|
||||
expect(requestBody).toEqual({
|
||||
agent_settings_diff: {
|
||||
llm: { model: "anthropic/claude-sonnet-4-20250514" },
|
||||
@@ -154,9 +156,6 @@ describe("cloud settings", () => {
|
||||
|
||||
describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () => {
|
||||
it("strips a null agent_context while preserving sibling agent settings", async () => {
|
||||
// Arrange: the cloud rejects agent_context: null against OpenHandsAgentSettings.
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
|
||||
// Act
|
||||
await saveCloudSettings({
|
||||
agent_settings_diff: {
|
||||
@@ -166,8 +165,8 @@ describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () =>
|
||||
});
|
||||
|
||||
// Assert: agent_context never reaches the wire, but the real llm change does.
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const requestBody = (config as { data: Record<string, unknown> }).data;
|
||||
const [, init] = getFetchCall(fetchMock);
|
||||
const requestBody = getJsonBody(init);
|
||||
expect(requestBody).toEqual({
|
||||
agent_settings_diff: {
|
||||
llm: { model: "anthropic/claude-sonnet-4-20250514" },
|
||||
@@ -176,32 +175,26 @@ describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () =>
|
||||
});
|
||||
|
||||
it("preserves a null mcp_config so clearing MCP servers still round-trips", async () => {
|
||||
// Arrange: mcp_config: null is an intentional "clear" signal, not an error.
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
|
||||
// Act
|
||||
await saveCloudSettings({
|
||||
agent_settings_diff: { mcp_config: null },
|
||||
});
|
||||
|
||||
// Assert: the null mcp_config must survive (don't over-strip nulls).
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const requestBody = (config as { data: Record<string, unknown> }).data;
|
||||
const [, init] = getFetchCall(fetchMock);
|
||||
const requestBody = getJsonBody(init);
|
||||
expect(requestBody).toEqual({ agent_settings_diff: { mcp_config: null } });
|
||||
});
|
||||
|
||||
it("omits agent_settings_diff when agent_context: null is its only key", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
|
||||
// Act
|
||||
await saveCloudSettings({
|
||||
agent_settings_diff: { agent_context: null },
|
||||
});
|
||||
|
||||
// Assert: nothing is left to send, so no agent_settings_diff goes on the wire.
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const requestBody = (config as { data: Record<string, unknown> }).data;
|
||||
const [, init] = getFetchCall(fetchMock);
|
||||
const requestBody = getJsonBody(init);
|
||||
expect(requestBody).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -7,8 +6,7 @@ import {
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import SkillsService from "#/api/skills-service";
|
||||
|
||||
vi.mock("axios");
|
||||
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
@@ -18,24 +16,30 @@ const cloudBackend: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("SkillsService.getSkills against cloud backend", () => {
|
||||
it("paginates /api/v1/skills/search directly and returns the merged list", async () => {
|
||||
vi.mocked(axios.request)
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
items: [
|
||||
{ name: "alpha", type: "knowledge", source: "global" },
|
||||
{
|
||||
@@ -46,31 +50,31 @@ describe("SkillsService.getSkills against cloud backend", () => {
|
||||
},
|
||||
],
|
||||
next_page_id: "beta",
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
items: [{ name: "gamma", type: "knowledge", source: "user" }],
|
||||
next_page_id: null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const skills = await SkillsService.getSkills();
|
||||
|
||||
expect(vi.mocked(axios.request)).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [firstConfig] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(firstConfig).toMatchObject({
|
||||
const [firstUrl, firstInit] = getFetchCall(fetchMock, 0);
|
||||
expect(firstInit).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect((firstConfig as { url: string }).url).toMatch(
|
||||
expect(firstUrl).toMatch(
|
||||
/^https:\/\/app\.all-hands\.dev\/api\/v1\/skills\/search\?/,
|
||||
);
|
||||
expect((firstConfig as { url: string }).url).not.toContain("page_id=");
|
||||
expect(firstUrl).not.toContain("page_id=");
|
||||
|
||||
const [secondConfig] = vi.mocked(axios.request).mock.calls[1]!;
|
||||
expect((secondConfig as { url: string }).url).toContain("page_id=beta");
|
||||
const [secondUrl] = getFetchCall(fetchMock, 1);
|
||||
expect(secondUrl).toContain("page_id=beta");
|
||||
|
||||
expect(skills.map((s) => s.name)).toEqual(["alpha", "beta", "gamma"]);
|
||||
expect(skills[1]).toMatchObject({ triggers: ["foo"] });
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -7,8 +6,7 @@ import {
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import { getCloudSuggestedTasks } from "#/api/cloud/suggestions-service.api";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
vi.mock("axios");
|
||||
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
@@ -18,19 +16,24 @@ const cloudBackend: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: { items: [], next_page_id: null },
|
||||
});
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(
|
||||
mockJsonResponse({ items: [], next_page_id: null }),
|
||||
);
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("getCloudSuggestedTasks", () => {
|
||||
@@ -39,8 +42,11 @@ describe("getCloudSuggestedTasks", () => {
|
||||
await getCloudSuggestedTasks({ limit: 10, pageId: "p2" });
|
||||
|
||||
// Assert
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const url = (config as { url: string }).url;
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect(url).toContain("/api/v1/git/suggested-tasks/search");
|
||||
expect(url).toContain("limit=10");
|
||||
expect(url).toContain("page_id=p2");
|
||||
|
||||
@@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AxiosError, AxiosHeaders } from "axios";
|
||||
import { HttpError } from "@openhands/typescript-client";
|
||||
import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
|
||||
import { useBashCommandLogs } from "#/hooks/query/use-bash-command-logs";
|
||||
|
||||
@@ -262,6 +263,103 @@ describe("useBashCommandLogs — cloud sandbox state handling", () => {
|
||||
await waitFor(() => expect(result.current.error).toBeTruthy());
|
||||
expect(result.current.sandboxIssue).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: 404, label: "404 not-found" },
|
||||
{ status: 503, label: "503 unavailable" },
|
||||
])(
|
||||
"classifies shared-client HttpError $label responses as sandboxIssue=unreachable",
|
||||
async ({ status }) => {
|
||||
setConversation({
|
||||
conversation_url: "https://runtime.example.com",
|
||||
sandbox_status: "RUNNING",
|
||||
});
|
||||
listOutputsMock.mockRejectedValueOnce(
|
||||
new HttpError(status, "Error", { detail: "sandbox gone" }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useBashCommandLogs({
|
||||
conversationId: "conv-1",
|
||||
bashCommandId: "cmd-1",
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.sandboxIssue).toBe("unreachable"),
|
||||
);
|
||||
expect(result.current.error).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("does NOT collapse an HttpError 401 into unreachable (auth bugs surface as errors)", async () => {
|
||||
setConversation({
|
||||
conversation_url: "https://runtime.example.com",
|
||||
sandbox_status: "RUNNING",
|
||||
});
|
||||
listOutputsMock.mockRejectedValueOnce(
|
||||
new HttpError(401, "Unauthorized", { detail: "bad token" }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useBashCommandLogs({
|
||||
conversationId: "conv-1",
|
||||
bashCommandId: "cmd-1",
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeTruthy());
|
||||
expect(result.current.sandboxIssue).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "network TypeError",
|
||||
makeError: () => new TypeError("Failed to fetch"),
|
||||
},
|
||||
{
|
||||
label: "TimeoutError abort",
|
||||
makeError: () =>
|
||||
Object.assign(new Error("The operation timed out"), {
|
||||
name: "TimeoutError",
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "timeout wrapped with cause",
|
||||
makeError: () =>
|
||||
new Error("Request timeout after 30000ms", {
|
||||
cause: Object.assign(new Error("timed out"), {
|
||||
name: "TimeoutError",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
])(
|
||||
"classifies fetch transport failures ($label) as sandboxIssue=unreachable",
|
||||
async ({ makeError }) => {
|
||||
setConversation({
|
||||
conversation_url: "https://runtime.example.com",
|
||||
sandbox_status: "RUNNING",
|
||||
});
|
||||
listOutputsMock.mockRejectedValueOnce(makeError());
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useBashCommandLogs({
|
||||
conversationId: "conv-1",
|
||||
bashCommandId: "cmd-1",
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.sandboxIssue).toBe("unreachable"),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("useBashCommandLogs — local backend", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { HttpError } from "@openhands/typescript-client";
|
||||
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
@@ -153,6 +154,24 @@ describe("AutomationDetail — Edit is local-only", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AutomationDetail — not-found handling", () => {
|
||||
it("renders the not-found state when the lookup rejects with an HttpError 404", async () => {
|
||||
// Arrange — cloud transport 404s surface as the shared client's HttpError.
|
||||
vi.mocked(AutomationService.getAutomation).mockRejectedValue(
|
||||
new HttpError(404, "Not Found", { detail: "No such automation" }),
|
||||
);
|
||||
|
||||
// Act
|
||||
renderDetail();
|
||||
|
||||
// Assert — the 404 branch renders NotFoundState instead of the generic
|
||||
// error state.
|
||||
expect(
|
||||
await screen.findByText(I18nKey.AUTOMATIONS$DETAIL$NOT_FOUND_TITLE),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AutomationDetail — backend-change guard", () => {
|
||||
it("does not fetch the automation again when the active backend changes after mount", async () => {
|
||||
// Arrange — the page mounts under the local backend; the id in the URL
|
||||
|
||||
@@ -4,6 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { HttpError } from "@openhands/typescript-client";
|
||||
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
@@ -315,6 +316,28 @@ describe("AutomationsList — Run now toasts", () => {
|
||||
});
|
||||
expect(displaySuccessToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the server-provided message when the dispatch API rejects with an HttpError", async () => {
|
||||
// Arrange — cloud transport failures surface as the shared client's
|
||||
// HttpError, whose parsed body lives on `response`.
|
||||
vi.mocked(AutomationService.dispatchAutomation).mockRejectedValue(
|
||||
new HttpError(500, "Internal Server Error", {
|
||||
message: "Runner quota exceeded",
|
||||
}),
|
||||
);
|
||||
const { displayErrorToast } = await import("#/utils/custom-toast-handlers");
|
||||
const user = userEvent.setup();
|
||||
renderList();
|
||||
await screen.findByText(automation.name);
|
||||
|
||||
// Act — click the row's "Run now" button.
|
||||
await user.click(screen.getByTestId(`automation-run-now-${automation.id}`));
|
||||
|
||||
// Assert — the toast shows the body's message, not a generic fallback.
|
||||
await waitFor(() => {
|
||||
expect(displayErrorToast).toHaveBeenCalledWith("Runner quota exceeded");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AutomationsList — list freshness on remount", () => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AxiosError } from "axios";
|
||||
import { HttpError } from "@openhands/typescript-client";
|
||||
import { getApiErrorMessage } from "#/utils/api-error-message";
|
||||
|
||||
describe("getApiErrorMessage", () => {
|
||||
it("returns the body `detail` from an HttpError when no `message` is present", () => {
|
||||
// Arrange — FastAPI-style error body on the shared client's HttpError.
|
||||
const error = new HttpError(422, "Unprocessable Entity", {
|
||||
detail: "Automation spec is invalid",
|
||||
});
|
||||
|
||||
// Act + Assert
|
||||
expect(getApiErrorMessage(error, "fallback")).toBe(
|
||||
"Automation spec is invalid",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the response body `message` from an axios error", () => {
|
||||
// Arrange — local agent-server calls still reject with AxiosError.
|
||||
const error = new AxiosError("Request failed with status code 500");
|
||||
error.response = {
|
||||
status: 500,
|
||||
data: { message: "Runner exploded" },
|
||||
} as never;
|
||||
|
||||
// Act + Assert
|
||||
expect(getApiErrorMessage(error, "fallback")).toBe("Runner exploded");
|
||||
});
|
||||
|
||||
it("returns the fallback when the error carries no usable information", () => {
|
||||
expect(getApiErrorMessage(null, "fallback")).toBe("fallback");
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import type { Backend } from "#/api/backend-registry/types";
|
||||
import type { Automation, AutomationSpec } from "#/types/automation";
|
||||
import AutomationService from "./automation-service.api";
|
||||
|
||||
const { localAxios, axiosRequest } = vi.hoisted(() => ({
|
||||
const { localAxios, callCloudProxy } = vi.hoisted(() => ({
|
||||
localAxios: {
|
||||
interceptors: { request: { use: vi.fn() } },
|
||||
get: vi.fn(),
|
||||
@@ -15,17 +15,20 @@ const { localAxios, axiosRequest } = vi.hoisted(() => ({
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
axiosRequest: vi.fn(),
|
||||
callCloudProxy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
create: () => localAxios,
|
||||
request: axiosRequest,
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/api/cloud/proxy", () => ({
|
||||
callCloudProxy,
|
||||
}));
|
||||
|
||||
const localBackend: Backend = {
|
||||
id: "local-test",
|
||||
name: "Local test backend",
|
||||
@@ -169,32 +172,30 @@ describe("AutomationService.createAutomation", () => {
|
||||
it("uses the selected cloud backend and organization for both requests", async () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" });
|
||||
axiosRequest
|
||||
.mockResolvedValueOnce({ data: createdAutomation })
|
||||
.mockResolvedValueOnce({
|
||||
data: { ...createdAutomation, enabled: false },
|
||||
});
|
||||
callCloudProxy
|
||||
.mockResolvedValueOnce(createdAutomation)
|
||||
.mockResolvedValueOnce({ ...createdAutomation, enabled: false });
|
||||
|
||||
const created = await AutomationService.createAutomation(spec);
|
||||
|
||||
expect(axiosRequest).toHaveBeenNthCalledWith(
|
||||
expect(callCloudProxy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
url: `${cloudBackend.host}/api/automation/v1/preset/plugin`,
|
||||
backend: cloudBackend,
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: `Bearer ${cloudBackend.apiKey}`,
|
||||
"X-Org-Id": "org-1",
|
||||
}),
|
||||
path: "/api/automation/v1/preset/plugin",
|
||||
body: expect.objectContaining({ name: spec.name }),
|
||||
headers: { "X-Org-Id": "org-1" },
|
||||
}),
|
||||
);
|
||||
expect(axiosRequest).toHaveBeenNthCalledWith(
|
||||
expect(callCloudProxy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
url: `${cloudBackend.host}/api/automation/v1/created-automation`,
|
||||
backend: cloudBackend,
|
||||
method: "PATCH",
|
||||
data: expect.objectContaining({ enabled: false }),
|
||||
headers: expect.objectContaining({ "X-Org-Id": "org-1" }),
|
||||
path: "/api/automation/v1/created-automation",
|
||||
body: expect.objectContaining({ enabled: false }),
|
||||
headers: { "X-Org-Id": "org-1" },
|
||||
}),
|
||||
);
|
||||
expect(created.enabled).toBe(false);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { CloudClient } from "@openhands/typescript-client/clients";
|
||||
import {
|
||||
getAgentServerBaseUrl,
|
||||
getAgentServerHeaders,
|
||||
} from "../agent-server-config";
|
||||
import { NoBackendAvailableError } from "../agent-server-client-options";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
|
||||
function requireCloudBackend(backend?: Backend): Backend {
|
||||
if (backend) return backend;
|
||||
const active = getActiveBackend().backend;
|
||||
if (active.kind !== "cloud") {
|
||||
throw new Error("Cloud calls require a cloud backend.");
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send `X-Org-Id` so the upstream scopes per-request to the org the user
|
||||
* selected locally, instead of the user's globally-shared `current_org_id`
|
||||
* on the cloud backend. Restricted to calls against the active backend: the
|
||||
* selector also fans out per-backend bookkeeping calls (e.g.
|
||||
* `getCloudOrganizations(b)`) that would otherwise carry the active
|
||||
* backend's orgId across an unrelated API key, which the cloud backend
|
||||
* rejects when api_key_org_id and X-Org-Id disagree.
|
||||
*/
|
||||
function activeOrgForBackend(backend: Backend): string | null {
|
||||
const active = getActiveBackend();
|
||||
return active.backend.id === backend.id ? active.orgId : null;
|
||||
}
|
||||
|
||||
export function createCloudClient(backend?: Backend): CloudClient {
|
||||
const target = requireCloudBackend(backend);
|
||||
const proxyBaseUrl = getAgentServerBaseUrl();
|
||||
const proxyHeaders = proxyBaseUrl ? getAgentServerHeaders() : {};
|
||||
|
||||
return new CloudClient({
|
||||
host: target.host,
|
||||
apiKey: target.apiKey,
|
||||
orgId: activeOrgForBackend(target),
|
||||
// Default request timeout in ms, matching the 30s the previous axios
|
||||
// transport used for direct and proxied calls. Per-request
|
||||
// `timeoutSeconds` still overrides it for direct calls.
|
||||
timeout: 30_000,
|
||||
...(proxyBaseUrl
|
||||
? {
|
||||
proxy: {
|
||||
host: proxyBaseUrl,
|
||||
headers: proxyHeaders,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createCloudClientForRuntime(backend?: Backend): CloudClient {
|
||||
const client = createCloudClient(backend);
|
||||
if (!client.proxy) {
|
||||
throw new NoBackendAvailableError();
|
||||
}
|
||||
return client;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from "axios";
|
||||
import { HttpError } from "@openhands/typescript-client";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
import { callCloudProxy } from "./proxy";
|
||||
@@ -75,7 +75,7 @@ export async function getCurrentCloudApiKey(
|
||||
});
|
||||
return { orgId: data?.org_id ?? null, isLegacyKey: false };
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 400) {
|
||||
if (e instanceof HttpError && e.status === 400) {
|
||||
return { orgId: null, isLegacyKey: true };
|
||||
}
|
||||
throw e;
|
||||
|
||||
+20
-116
@@ -1,135 +1,39 @@
|
||||
import axios from "axios";
|
||||
import {
|
||||
getAgentServerBaseUrl,
|
||||
getAgentServerHeaders,
|
||||
} from "../agent-server-config";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import { NoBackendAvailableError } from "../agent-server-client-options";
|
||||
import { buildAuthHeaders } from "../backend-registry/auth";
|
||||
import type { CloudRequestOptions } from "@openhands/typescript-client/clients";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
import { createCloudClientForRuntime, createCloudClient } from "./client";
|
||||
|
||||
export interface CloudProxyRequest {
|
||||
/**
|
||||
* Cloud backend whose bearer token authenticates the upstream call.
|
||||
* `backend.host` is also the default upstream host unless `hostOverride`
|
||||
* is set.
|
||||
*/
|
||||
backend: Backend;
|
||||
/** HTTP method against the upstream host. */
|
||||
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
||||
/** Path on the upstream host, e.g. "/api/v1/conversation/123/events/search". */
|
||||
method: CloudRequestOptions["method"];
|
||||
path: string;
|
||||
/** Optional JSON body for non-GET methods. */
|
||||
body?: unknown;
|
||||
/** Extra headers merged with the auth header for the upstream call. */
|
||||
headers?: Record<string, string>;
|
||||
/** Override the upstream timeout, in seconds. */
|
||||
timeoutSeconds?: number;
|
||||
/**
|
||||
* Override the upstream host. When set, the proxy targets this host
|
||||
* instead of `backend.host`. Used for runtime-sandbox calls where the
|
||||
* upstream lives at the conversation's runtime URL (e.g.
|
||||
* `http://<id>.prod-runtime.all-hands.dev`) rather than the cloud API.
|
||||
* The host must still pass the proxy's allowlist server-side.
|
||||
*/
|
||||
hostOverride?: string;
|
||||
/**
|
||||
* Auth strategy for the upstream call. Defaults to "bearer" (uses the
|
||||
* cloud backend's bearer token via `buildAuthHeaders`). For
|
||||
* runtime-sandbox calls, set to "session-api-key" and pass
|
||||
* `sessionApiKey` — those endpoints don't accept bearer tokens, only
|
||||
* `X-Session-API-Key`. "none" sends no auth header.
|
||||
*/
|
||||
authMode?: "bearer" | "session-api-key" | "none";
|
||||
/** Required when `authMode === "session-api-key"`. */
|
||||
sessionApiKey?: string | null;
|
||||
/**
|
||||
* Axios responseType for the inner POST to the bundled agent-server.
|
||||
* Set to "blob" when the upstream cloud endpoint returns a binary
|
||||
* payload (e.g. ZIP downloads); leave undefined for default JSON.
|
||||
*/
|
||||
responseType?: "blob";
|
||||
}
|
||||
|
||||
function buildUpstreamAuthHeaders(
|
||||
req: CloudProxyRequest,
|
||||
): Record<string, string> {
|
||||
const mode = req.authMode ?? "bearer";
|
||||
if (mode === "bearer") return buildAuthHeaders(req.backend);
|
||||
if (mode === "session-api-key") {
|
||||
return req.sessionApiKey ? { "X-Session-API-Key": req.sessionApiKey } : {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a cloud request. App-host calls (`backend.host`) go directly to the
|
||||
* cloud API with the cloud backend's auth headers. Runtime-sandbox calls
|
||||
* pass `hostOverride`, and those go through `/api/cloud-proxy` because the
|
||||
* per-conversation runtime hosts are not the configured cloud app origin.
|
||||
*
|
||||
* App-host auth headers are sent directly to the cloud host. Proxied auth
|
||||
* headers are carried in the proxy envelope and attached server-side.
|
||||
*/
|
||||
export async function callCloudProxy<TResponse = unknown>(
|
||||
req: CloudProxyRequest,
|
||||
): Promise<TResponse> {
|
||||
// Send `X-Org-Id` so the upstream scopes per-request to the org the user
|
||||
// selected locally, instead of the user's globally-shared
|
||||
// `current_org_id` on the cloud backend. Restricted to calls against the active
|
||||
// backend: the selector also fans out per-backend bookkeeping calls
|
||||
// (e.g. `getCloudOrganizations(b)`) that would otherwise carry the
|
||||
// active backend's orgId across an unrelated API key, which the cloud backend
|
||||
// rejects when api_key_org_id and X-Org-Id disagree.
|
||||
const active = getActiveBackend();
|
||||
const orgIdHeader =
|
||||
active.backend.id === req.backend.id && active.orgId
|
||||
? { "X-Org-Id": active.orgId }
|
||||
: {};
|
||||
const upstreamHeaders = {
|
||||
...buildUpstreamAuthHeaders(req),
|
||||
...orgIdHeader,
|
||||
...(req.headers ?? {}),
|
||||
};
|
||||
const upstreamHost = req.hostOverride ?? req.backend.host;
|
||||
const client = req.hostOverride
|
||||
? createCloudClientForRuntime(req.backend)
|
||||
: createCloudClient(req.backend);
|
||||
|
||||
if (!req.hostOverride) {
|
||||
const response = await axios.request<TResponse>({
|
||||
url: `${upstreamHost.replace(/\/+$/, "")}${req.path}`,
|
||||
method: req.method,
|
||||
headers: upstreamHeaders,
|
||||
...(req.body !== undefined ? { data: req.body } : {}),
|
||||
timeout: (req.timeoutSeconds ?? 30) * 1000,
|
||||
...(req.responseType ? { responseType: req.responseType } : {}),
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
const proxyBaseUrl = getAgentServerBaseUrl();
|
||||
if (!proxyBaseUrl) throw new NoBackendAvailableError();
|
||||
const localAuthHeaders = getAgentServerHeaders();
|
||||
|
||||
// Talk to the configured app/ingress origin that exposes /api/cloud-proxy.
|
||||
// Do not resolve this through the backend registry: when the active backend
|
||||
// is cloud, borrowing some other registered local backend would silently
|
||||
// route cloud traffic through the wrong user-configured server.
|
||||
const response = await axios.post<TResponse>(
|
||||
`${proxyBaseUrl.replace(/\/+$/, "")}/api/cloud-proxy`,
|
||||
{
|
||||
host: upstreamHost,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
headers: upstreamHeaders,
|
||||
body: req.body ?? null,
|
||||
...(req.timeoutSeconds ? { timeout_seconds: req.timeoutSeconds } : {}),
|
||||
},
|
||||
{
|
||||
headers: localAuthHeaders,
|
||||
timeout: 30_000,
|
||||
...(req.responseType ? { responseType: req.responseType } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return client.request<TResponse>({
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
body: req.body,
|
||||
headers: req.headers,
|
||||
timeoutSeconds: req.timeoutSeconds,
|
||||
hostOverride: req.hostOverride,
|
||||
authMode:
|
||||
req.authMode === undefined || req.authMode === "bearer"
|
||||
? "bearer"
|
||||
: req.authMode,
|
||||
sessionApiKey: req.sessionApiKey,
|
||||
responseType: req.responseType,
|
||||
});
|
||||
}
|
||||
|
||||
+28
-319
@@ -1,334 +1,43 @@
|
||||
/**
|
||||
* OAuth 2.0 Device Flow client implementation (RFC 8628).
|
||||
*
|
||||
* Used for one-click authentication with cloud backends.
|
||||
* The flow allows users to authenticate in their browser while the
|
||||
* application polls for the resulting API key.
|
||||
*
|
||||
* Device flow requests go directly to the configured OpenHands Cloud host.
|
||||
*/
|
||||
export {
|
||||
DeviceFlowError,
|
||||
pollForToken,
|
||||
startDeviceFlow,
|
||||
} from "@openhands/typescript-client/clients";
|
||||
import { isOpenHandsCloudHost as sdkIsOpenHandsCloudHost } from "@openhands/typescript-client/clients";
|
||||
|
||||
export class DeviceFlowError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DeviceFlowError";
|
||||
}
|
||||
const OPENHANDS_CLOUD_HOST_SUFFIXES = ["all-hands.dev", "openhands.dev"];
|
||||
|
||||
function isAllowedCloudHostname(hostname: string): boolean {
|
||||
return OPENHANDS_CLOUD_HOST_SUFFIXES.some(
|
||||
(suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`),
|
||||
);
|
||||
}
|
||||
|
||||
export interface DeviceAuthorizationResponse {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
verification_uri_complete: string;
|
||||
expires_in: number;
|
||||
interval: number;
|
||||
}
|
||||
function fallbackIsOpenHandsCloudHost(host: string): boolean {
|
||||
if (!host.trim()) return false;
|
||||
|
||||
export interface DeviceTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
interface DeviceTokenErrorResponse {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
|
||||
const MAX_INTERVAL_MS = 30_000; // 30 seconds max polling interval
|
||||
|
||||
/**
|
||||
* Check if a host is a known OpenHands Cloud domain.
|
||||
* Uses hostname extraction to prevent substring matching attacks.
|
||||
*/
|
||||
export function isOpenHandsCloudHost(host: string): boolean {
|
||||
try {
|
||||
// Extract hostname from URL or treat as hostname if no protocol
|
||||
const trimmed = host.trim().toLowerCase();
|
||||
const withProtocol = /^https?:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: `https://${trimmed}`;
|
||||
const url = new URL(withProtocol);
|
||||
const hostname = url.hostname;
|
||||
|
||||
// Check if hostname ends with known domains (exact suffix match)
|
||||
return (
|
||||
hostname.endsWith(".all-hands.dev") ||
|
||||
hostname === "all-hands.dev" ||
|
||||
hostname.endsWith(".openhands.dev") ||
|
||||
hostname === "openhands.dev"
|
||||
);
|
||||
const normalizedHost = host.includes("://") ? host : `https://${host}`;
|
||||
const { hostname } = new URL(normalizedHost);
|
||||
return isAllowedCloudHostname(hostname.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a direct request to the OpenHands Cloud device-flow endpoint.
|
||||
*/
|
||||
async function makeCloudRequest(
|
||||
upstreamHost: string,
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
contentType?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const requestBody =
|
||||
body === undefined
|
||||
? undefined
|
||||
: typeof body === "string" ||
|
||||
body instanceof Blob ||
|
||||
body instanceof FormData ||
|
||||
body instanceof URLSearchParams
|
||||
? body
|
||||
: JSON.stringify(body);
|
||||
|
||||
const response = await fetch(`${upstreamHost.replace(/\/+$/, "")}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
},
|
||||
body: requestBody,
|
||||
signal,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the OAuth 2.0 Device Flow by requesting a device code.
|
||||
* Requests are sent directly to the cloud host.
|
||||
*
|
||||
* @param host - The cloud backend host URL (e.g., "https://app.all-hands.dev")
|
||||
* @returns DeviceAuthorizationResponse with device_code, user_code, verification URLs, etc.
|
||||
* @throws DeviceFlowError if the request fails
|
||||
*/
|
||||
export async function startDeviceFlow(
|
||||
host: string,
|
||||
): Promise<DeviceAuthorizationResponse> {
|
||||
const normalizedHost = host.replace(/\/+$/, "");
|
||||
|
||||
export function isOpenHandsCloudHost(host: string): boolean {
|
||||
try {
|
||||
const response = await makeCloudRequest(
|
||||
normalizedHost,
|
||||
"POST",
|
||||
"/oauth/device/authorize",
|
||||
{},
|
||||
"application/json",
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// Avoid exposing sensitive server error details
|
||||
throw new DeviceFlowError(
|
||||
`Failed to start device flow: Server returned ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Validate required fields per RFC 8628 Section 3.2
|
||||
// verification_uri_complete is OPTIONAL per RFC
|
||||
if (!data.device_code || !data.user_code || !data.verification_uri) {
|
||||
throw new DeviceFlowError(
|
||||
"Invalid response from device authorization endpoint: missing required fields",
|
||||
);
|
||||
}
|
||||
|
||||
// Build verification_uri_complete if not provided (optional per RFC)
|
||||
const verificationUriComplete =
|
||||
data.verification_uri_complete ??
|
||||
`${data.verification_uri}?user_code=${encodeURIComponent(data.user_code)}`;
|
||||
|
||||
return {
|
||||
device_code: data.device_code,
|
||||
user_code: data.user_code,
|
||||
verification_uri: data.verification_uri,
|
||||
verification_uri_complete: verificationUriComplete,
|
||||
expires_in: data.expires_in ?? 600,
|
||||
interval: data.interval ?? 5,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DeviceFlowError) {
|
||||
throw error;
|
||||
}
|
||||
throw new DeviceFlowError(
|
||||
`Failed to start device flow: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface PollOptions {
|
||||
/** Polling interval in seconds (from device authorization response) */
|
||||
interval: number;
|
||||
/** Maximum time to wait for authorization in milliseconds */
|
||||
timeout?: number;
|
||||
/** Abort signal to cancel polling */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for the API key after user authorization.
|
||||
* Requests are sent directly to the cloud host.
|
||||
*
|
||||
* @param host - The cloud backend host URL
|
||||
* @param deviceCode - The device code from startDeviceFlow
|
||||
* @param options - Polling options including interval, timeout, and abort signal
|
||||
* @returns DeviceTokenResponse containing the access_token (API key)
|
||||
* @throws DeviceFlowError if polling fails, user denies access, or timeout expires
|
||||
*/
|
||||
export async function pollForToken(
|
||||
host: string,
|
||||
deviceCode: string,
|
||||
options: PollOptions,
|
||||
): Promise<DeviceTokenResponse> {
|
||||
const normalizedHost = host.replace(/\/+$/, "");
|
||||
const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||
let interval = Math.max(1, options.interval) * 1000; // At least 1 second
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
// Check if cancelled
|
||||
if (options.signal?.aborted) {
|
||||
throw new DeviceFlowError("Authorization cancelled", "cancelled");
|
||||
}
|
||||
|
||||
try {
|
||||
// RFC 8628 Section 3.4 requires grant_type parameter
|
||||
const tokenRequestBody = new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: deviceCode,
|
||||
}).toString();
|
||||
|
||||
const response = await makeCloudRequest(
|
||||
normalizedHost,
|
||||
"POST",
|
||||
"/oauth/device/token",
|
||||
tokenRequestBody,
|
||||
"application/x-www-form-urlencoded",
|
||||
options.signal,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (!data.access_token) {
|
||||
throw new DeviceFlowError(
|
||||
"Invalid token response: missing access_token",
|
||||
);
|
||||
}
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
token_type: data.token_type ?? "Bearer",
|
||||
expires_in: data.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle error responses
|
||||
let errorData: DeviceTokenErrorResponse;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
throw new DeviceFlowError(
|
||||
`Unexpected response from server: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { error, error_description } = errorData;
|
||||
|
||||
switch (error) {
|
||||
case "authorization_pending":
|
||||
// User hasn't finished yet; continue polling
|
||||
break;
|
||||
|
||||
case "slow_down":
|
||||
// Server asks us to poll less frequently
|
||||
// RFC 8628 Section 3.5: "the client MUST increase its polling interval by 5 seconds"
|
||||
// Validate server-provided interval to prevent DoS (must be number, finite, positive)
|
||||
if (
|
||||
typeof errorData.interval === "number" &&
|
||||
isFinite(errorData.interval) &&
|
||||
errorData.interval > 0
|
||||
) {
|
||||
interval = Math.max(1, Math.min(errorData.interval, 30)) * 1000;
|
||||
} else {
|
||||
// RFC 8628 mandates incrementing by 5 seconds
|
||||
interval = Math.min(interval + 5000, MAX_INTERVAL_MS);
|
||||
}
|
||||
break;
|
||||
|
||||
case "expired_token":
|
||||
throw new DeviceFlowError(
|
||||
"Device code has expired. Please try again.",
|
||||
"expired_token",
|
||||
);
|
||||
|
||||
case "access_denied":
|
||||
throw new DeviceFlowError(
|
||||
"Authorization request was denied.",
|
||||
"access_denied",
|
||||
);
|
||||
|
||||
default:
|
||||
throw new DeviceFlowError(
|
||||
`Authorization error: ${error}${error_description ? ` - ${error_description}` : ""}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// DeviceFlowError means a definitive error (denied, expired, etc.) - rethrow
|
||||
if (error instanceof DeviceFlowError) {
|
||||
throw error;
|
||||
}
|
||||
// User cancelled - rethrow
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new DeviceFlowError("Authorization cancelled", "cancelled");
|
||||
}
|
||||
// Network errors during polling should continue until timeout, not fail immediately
|
||||
// Brief network hiccups shouldn't abort 10-minute flows
|
||||
console.warn("Network error during polling, retrying:", error);
|
||||
}
|
||||
|
||||
// Wait before next poll (wrap in try-catch for consistent abort handling)
|
||||
try {
|
||||
await sleep(interval, options.signal);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new DeviceFlowError("Authorization cancelled", "cancelled");
|
||||
}
|
||||
throw error;
|
||||
if (typeof sdkIsOpenHandsCloudHost === "function") {
|
||||
return sdkIsOpenHandsCloudHost(host);
|
||||
}
|
||||
} catch {
|
||||
return fallbackIsOpenHandsCloudHost(host);
|
||||
}
|
||||
|
||||
throw new DeviceFlowError(
|
||||
"Timeout waiting for authorization. Please try again.",
|
||||
"timeout",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for a given duration, respecting an optional abort signal.
|
||||
*/
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(resolve, ms);
|
||||
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
return fallbackIsOpenHandsCloudHost(host);
|
||||
}
|
||||
export type {
|
||||
DeviceAuthorizationResponse,
|
||||
DeviceTokenResponse,
|
||||
PollDeviceTokenOptions as PollOptions,
|
||||
} from "@openhands/typescript-client/clients";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
|
||||
import { isAxiosError } from "axios";
|
||||
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
|
||||
import { mapAnyGitStatusToClientStatus } from "#/utils/git-status-mapper";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import type {
|
||||
@@ -97,8 +97,9 @@ async function getFromRuntime<T>(
|
||||
/**
|
||||
* The commit endpoints are newer than some deployed agent servers. A 404
|
||||
* (route absent) means "server too old" — callers hide the commits section
|
||||
* instead of surfacing an error. Local calls throw the SDK's `HttpError`;
|
||||
* cloud-proxy calls throw an `AxiosError`.
|
||||
* instead of surfacing an error. Local calls and cloud-proxy calls (via the
|
||||
* shared TypeScript client) both throw the SDK's `HttpError`; the axios
|
||||
* check is kept as a safety net for axios-shaped errors.
|
||||
*/
|
||||
function isEndpointMissingError(error: unknown): boolean {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SettingsClient } from "@openhands/typescript-client/clients";
|
||||
import { isSdkHttpStatusError } from "./agent-server-compatibility";
|
||||
import { getActiveBackend } from "./backend-registry/active-store";
|
||||
import {
|
||||
createCloudSecret,
|
||||
@@ -132,12 +133,16 @@ export class SecretsService {
|
||||
new SettingsClient(getAgentServerClientOptions()).deleteSecret(name),
|
||||
);
|
||||
} catch (error) {
|
||||
// 404 means secret doesn't exist - treat as successful deletion
|
||||
// 404 means secret doesn't exist - treat as successful deletion.
|
||||
// Both the SDK's HttpError (status on the error itself) and
|
||||
// axios-style errors (status under `response`) count.
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"response" in error &&
|
||||
(error as { response?: { status?: number } }).response?.status === 404
|
||||
isSdkHttpStatusError(error, 404) ||
|
||||
(error &&
|
||||
typeof error === "object" &&
|
||||
"response" in error &&
|
||||
(error as { response?: { status?: number } }).response?.status ===
|
||||
404)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isAxiosError } from "axios";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import type { Automation } from "#/types/automation";
|
||||
import { useUpdateAutomation } from "#/hooks/query/use-automations";
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
displaySuccessToast,
|
||||
displayErrorToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { getApiErrorMessage } from "#/utils/api-error-message";
|
||||
import { modalTitleLgMediumClassName } from "#/utils/modal-classes";
|
||||
import {
|
||||
parseCronSchedule,
|
||||
@@ -246,13 +246,9 @@ export function EditAutomationModal({
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isAxiosError(error)
|
||||
? (error.response?.data as { message?: string } | undefined)
|
||||
?.message ||
|
||||
error.message ||
|
||||
t(I18nKey.AUTOMATIONS$EDIT_ERROR)
|
||||
: (error as Error).message || t(I18nKey.AUTOMATIONS$EDIT_ERROR);
|
||||
displayErrorToast(message);
|
||||
displayErrorToast(
|
||||
getApiErrorMessage(error, t(I18nKey.AUTOMATIONS$EDIT_ERROR)),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import axios from "axios";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { HttpError } from "@openhands/typescript-client";
|
||||
import {
|
||||
ServerClient,
|
||||
SettingsClient,
|
||||
@@ -85,7 +86,10 @@ async function probeBackend(backend: Backend): Promise<true> {
|
||||
try {
|
||||
await getCurrentCloudApiKey(backend);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
if (
|
||||
(axios.isAxiosError(error) && error.response?.status === 401) ||
|
||||
(error instanceof HttpError && error.status === 401)
|
||||
) {
|
||||
throw new Error(CLOUD_BACKEND_LOGGED_OUT_ERROR);
|
||||
}
|
||||
if (isCorsOrNetworkError(error)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { isSdkHttpError } from "#/api/agent-server-compatibility";
|
||||
import BashService from "#/api/bash-service/bash-service.api";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import type { SandboxStatus } from "#/api/conversation-service/agent-server-conversation-service.types";
|
||||
@@ -56,21 +57,44 @@ function sandboxIssueFromStatus(
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect "the runtime is unreachable" axios errors from the cloud
|
||||
* proxy. The proxy itself returns 5xx when the upstream sandbox is
|
||||
* gone; runtimes return 4xx/5xx for various ephemeral states. We
|
||||
* classify 5xx and network errors as "unreachable" so the modal can
|
||||
* render the sandbox-gone state instead of dumping an axios string.
|
||||
* Detect "the runtime is unreachable" errors from the cloud proxy. The
|
||||
* proxy itself returns 5xx when the upstream sandbox is gone; runtimes
|
||||
* return 4xx/5xx for various ephemeral states. We classify 5xx and
|
||||
* network errors as "unreachable" so the modal can render the
|
||||
* sandbox-gone state instead of dumping a raw error. Cloud calls go
|
||||
* through the shared TypeScript client and throw its `HttpError`;
|
||||
* axios-shaped errors are still recognized as well.
|
||||
*/
|
||||
function classifyFetchError(error: unknown): SandboxIssue | null {
|
||||
if (!axios.isAxiosError(error)) return null;
|
||||
// No response at all → DNS/connect/abort
|
||||
if (!error.response) return "unreachable";
|
||||
const status = error.response.status;
|
||||
// Treat 502/503/504 (proxy can't reach upstream) and 404 (sandbox or
|
||||
// resource no longer exists) as the sandbox being gone. We do not
|
||||
// collapse 401/403 here — those are auth bugs we want to surface.
|
||||
if (status === 404 || status >= 500) return "unreachable";
|
||||
const status = axios.isAxiosError(error)
|
||||
? error.response?.status
|
||||
: isSdkHttpError(error)
|
||||
? (error as { status: number }).status
|
||||
: undefined;
|
||||
if (status !== undefined) {
|
||||
// Treat 502/503/504 (proxy can't reach upstream) and 404 (sandbox or
|
||||
// resource no longer exists) as the sandbox being gone. We do not
|
||||
// collapse 401/403 here — those are auth bugs we want to surface.
|
||||
return status === 404 || status >= 500 ? "unreachable" : null;
|
||||
}
|
||||
// No status → the request never got a response. Axios reports these as
|
||||
// response-less errors; fetch (the shared client) throws `TypeError`
|
||||
// for network failures and `AbortError`/`TimeoutError` for timeouts,
|
||||
// sometimes wrapped in a plain `Error` with the original as `cause`.
|
||||
if (axios.isAxiosError(error) || error instanceof TypeError) {
|
||||
return "unreachable";
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const causeName = error.cause instanceof Error ? error.cause.name : null;
|
||||
if (
|
||||
error.name === "AbortError" ||
|
||||
error.name === "TimeoutError" ||
|
||||
causeName === "AbortError" ||
|
||||
causeName === "TimeoutError"
|
||||
) {
|
||||
return "unreachable";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
displaySuccessToast,
|
||||
displayErrorToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { getApiErrorMessage } from "#/utils/api-error-message";
|
||||
import { getErrorStatus } from "#/hooks/query/use-settings";
|
||||
import { useAutomationDetail } from "#/hooks/query/use-automation-detail";
|
||||
import {
|
||||
useToggleAutomation,
|
||||
@@ -79,8 +80,7 @@ export default function AutomationDetail() {
|
||||
const deleteMutation = useDeleteAutomation();
|
||||
const dispatchMutation = useDispatchAutomation();
|
||||
|
||||
const is404 =
|
||||
isError && isAxiosError(error) && error.response?.status === 404;
|
||||
const is404 = isError && getErrorStatus(error) === 404;
|
||||
|
||||
// Show loading state while checking health
|
||||
if (isHealthLoading) {
|
||||
@@ -159,13 +159,9 @@ export default function AutomationDetail() {
|
||||
displaySuccessToast(t(I18nKey.AUTOMATIONS$RUN_NOW_SUCCESS));
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isAxiosError(error)
|
||||
? (error.response?.data as { message?: string } | undefined)
|
||||
?.message ||
|
||||
error.message ||
|
||||
t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR)
|
||||
: (error as Error).message || t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR);
|
||||
displayErrorToast(message);
|
||||
displayErrorToast(
|
||||
getApiErrorMessage(error, t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR)),
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
} from "react";
|
||||
import { FileUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isAxiosError } from "axios";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
displaySuccessToast,
|
||||
displaySuccessToastWithLink,
|
||||
displayErrorToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { getApiErrorMessage } from "#/utils/api-error-message";
|
||||
import {
|
||||
useAutomations,
|
||||
useToggleAutomation,
|
||||
@@ -134,13 +134,9 @@ export default function AutomationsList() {
|
||||
displaySuccessToast(t(I18nKey.AUTOMATIONS$RUN_NOW_SUCCESS));
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isAxiosError(error)
|
||||
? (error.response?.data as { message?: string } | undefined)
|
||||
?.message ||
|
||||
error.message ||
|
||||
t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR)
|
||||
: (error as Error).message || t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR);
|
||||
displayErrorToast(message);
|
||||
displayErrorToast(
|
||||
getApiErrorMessage(error, t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR)),
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -205,16 +201,9 @@ export default function AutomationsList() {
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
const detail = isAxiosError(error)
|
||||
? (error.response?.data as { detail?: unknown } | undefined)?.detail
|
||||
: undefined;
|
||||
const message =
|
||||
typeof detail === "string"
|
||||
? detail
|
||||
: isAxiosError(error)
|
||||
? error.message
|
||||
: t(I18nKey.ERROR$GENERIC);
|
||||
displayErrorToast(message);
|
||||
displayErrorToast(
|
||||
getApiErrorMessage(error, t(I18nKey.ERROR$GENERIC)),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Extract a human-readable message from a failed API call.
|
||||
*
|
||||
* Handles both transports the app uses: local agent-server calls that
|
||||
* throw an `AxiosError` (body under `error.response.data`) and cloud
|
||||
* calls through the shared TypeScript client that throw an `HttpError`
|
||||
* (parsed body directly under `error.response`). Prefers the
|
||||
* server-provided `message`/`detail` fields, then the `Error` message,
|
||||
* then `fallback`.
|
||||
*/
|
||||
export function getApiErrorMessage(error: unknown, fallback: string): string {
|
||||
const body = axios.isAxiosError(error)
|
||||
? error.response?.data
|
||||
: error instanceof Error && "response" in error
|
||||
? (error as { response?: unknown }).response
|
||||
: undefined;
|
||||
|
||||
if (body && typeof body === "object") {
|
||||
const { message, detail } = body as {
|
||||
message?: unknown;
|
||||
detail?: unknown;
|
||||
};
|
||||
if (typeof message === "string" && message) return message;
|
||||
if (typeof detail === "string" && detail) return detail;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
Reference in New Issue
Block a user