mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
test: add edge case coverage and fix auto-detect status check
- Fix fetchRuntimeInfoAutoDetect in agent.ts to use 2xx-only check (was checking only 404/405, now aligns with agent-registry.ts) - Remove duplicate JSDoc block on fetchRuntimeInfoAutoDetect - Add 7 edge case tests: 500/403/405/network-error/both-fail scenarios - Add 4 tests for useSingleEndpoint->runtimeTransport ternary mapping - Update Angular test stubs to use "auto" default (was "rest") Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -70,7 +70,7 @@ class CopilotKitStub {
|
||||
CopilotKitCoreRuntimeConnectionStatus.Disconnected,
|
||||
);
|
||||
readonly #runtimeUrl = signal<string | undefined>(undefined);
|
||||
readonly #runtimeTransport = signal<"rest" | "single">("rest");
|
||||
readonly #runtimeTransport = signal<"rest" | "single" | "auto">("auto");
|
||||
readonly #headers = signal<Record<string, string>>({});
|
||||
getAgent = vi.fn((id: string) => this.#agents()[id]);
|
||||
agents = this.#agents.asReadonly();
|
||||
@@ -80,7 +80,7 @@ class CopilotKitStub {
|
||||
headers = this.#headers.asReadonly();
|
||||
core = {
|
||||
runtimeUrl: undefined as string | undefined,
|
||||
runtimeTransport: "rest" as const,
|
||||
runtimeTransport: "auto" as const,
|
||||
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Disconnected,
|
||||
headers: {} as Record<string, string>,
|
||||
};
|
||||
@@ -105,7 +105,7 @@ class CopilotKitStub {
|
||||
this.core = { ...this.core, headers: value };
|
||||
}
|
||||
|
||||
setRuntimeTransport(value: "rest" | "single") {
|
||||
setRuntimeTransport(value: "rest" | "single" | "auto") {
|
||||
this.#runtimeTransport.set(value);
|
||||
this.core = { ...this.core, runtimeTransport: value };
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ vi.mock("@copilotkit/core", () => {
|
||||
readonly getAgent = mockGetAgent;
|
||||
agents: Record<string, any> = {};
|
||||
runtimeUrl = undefined;
|
||||
runtimeTransport = "rest";
|
||||
runtimeTransport = "auto";
|
||||
headers: Record<string, string> = {};
|
||||
runtimeConnectionStatus =
|
||||
CopilotKitCoreRuntimeConnectionStatus.Disconnected;
|
||||
|
||||
@@ -498,6 +498,256 @@ describe("Auto-detect transport from runtime info response", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auto-detect transport edge cases (AgentRegistry)", () => {
|
||||
const originalFetch = global.fetch;
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
const infoResponse = {
|
||||
version: "1.0.0",
|
||||
agents: {
|
||||
remote: {
|
||||
description: "Remote agent",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { window?: unknown }).window = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
global.fetch = originalFetch;
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to single-endpoint when REST probe returns 500 with JSON body", async () => {
|
||||
const runtimeUrl = "https://runtime.example/auto-500";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation((url: string, init?: RequestInit) => {
|
||||
// REST attempt: GET /info → 500 with a JSON error body.
|
||||
// The bug: without the fix, the code treats any non-404/405 as REST
|
||||
// and parses this JSON as RuntimeInfo, corrupting the agent list.
|
||||
if (
|
||||
typeof url === "string" &&
|
||||
url.endsWith("/info") &&
|
||||
(!init?.method || init.method === "GET")
|
||||
) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ error: "Internal Server Error" }),
|
||||
{ status: 500, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}
|
||||
// Single-endpoint attempt: POST with { method: "info" }
|
||||
if (init?.method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(infoResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error("Unexpected fetch call"));
|
||||
});
|
||||
// @ts-expect-error - override in test environment
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const core = new CopilotKitCore({ runtimeUrl });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// First call: REST (GET /info → 500)
|
||||
const [url1] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url1).toBe(`${runtimeUrl}/info`);
|
||||
|
||||
// Second call: single-endpoint (POST)
|
||||
const [url2, init2] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||
expect(url2).toBe(runtimeUrl);
|
||||
expect(init2.method).toBe("POST");
|
||||
|
||||
// Agent registered, transport resolved to "single"
|
||||
expect(core.getAgent("remote")).toBeDefined();
|
||||
expect(core.runtimeTransport).toBe("single");
|
||||
});
|
||||
|
||||
it("falls back to single-endpoint when REST probe returns 403", async () => {
|
||||
const runtimeUrl = "https://runtime.example/auto-403";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (
|
||||
typeof url === "string" &&
|
||||
url.endsWith("/info") &&
|
||||
(!init?.method || init.method === "GET")
|
||||
) {
|
||||
return Promise.resolve(new Response("Forbidden", { status: 403 }));
|
||||
}
|
||||
if (init?.method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(infoResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error("Unexpected fetch call"));
|
||||
});
|
||||
// @ts-expect-error - override in test environment
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const core = new CopilotKitCore({ runtimeUrl });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(core.getAgent("remote")).toBeDefined();
|
||||
expect(core.runtimeTransport).toBe("single");
|
||||
});
|
||||
|
||||
it("falls back to single-endpoint when REST probe throws a network error", async () => {
|
||||
const runtimeUrl = "https://runtime.example/auto-net-err";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (
|
||||
typeof url === "string" &&
|
||||
url.endsWith("/info") &&
|
||||
(!init?.method || init.method === "GET")
|
||||
) {
|
||||
return Promise.reject(new TypeError("Failed to fetch"));
|
||||
}
|
||||
if (init?.method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(infoResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error("Unexpected fetch call"));
|
||||
});
|
||||
// @ts-expect-error - override in test environment
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const core = new CopilotKitCore({ runtimeUrl });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(core.getAgent("remote")).toBeDefined();
|
||||
expect(core.runtimeTransport).toBe("single");
|
||||
});
|
||||
|
||||
it("reports error when both REST and single-endpoint probes fail", async () => {
|
||||
const runtimeUrl = "https://runtime.example/auto-both-fail";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (
|
||||
typeof url === "string" &&
|
||||
url.endsWith("/info") &&
|
||||
(!init?.method || init.method === "GET")
|
||||
) {
|
||||
return Promise.resolve(new Response("Not Found", { status: 404 }));
|
||||
}
|
||||
if (init?.method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response("Internal Server Error", { status: 500 }),
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error("Unexpected fetch call"));
|
||||
});
|
||||
// @ts-expect-error - override in test environment
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const errorSpy = vi.fn();
|
||||
const core = new CopilotKitCore({ runtimeUrl });
|
||||
core.subscribe({
|
||||
onError: errorSpy,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Should have emitted an error since single-endpoint also returned 500
|
||||
// The connection status should be Error
|
||||
await vi.waitFor(() => {
|
||||
expect(core.runtimeConnectionStatus).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to single-endpoint when REST probe returns 405", async () => {
|
||||
const runtimeUrl = "https://runtime.example/auto-405";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (
|
||||
typeof url === "string" &&
|
||||
url.endsWith("/info") &&
|
||||
(!init?.method || init.method === "GET")
|
||||
) {
|
||||
return Promise.resolve(
|
||||
new Response("Method Not Allowed", { status: 405 }),
|
||||
);
|
||||
}
|
||||
if (init?.method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(infoResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error("Unexpected fetch call"));
|
||||
});
|
||||
// @ts-expect-error - override in test environment
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const core = new CopilotKitCore({ runtimeUrl });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(core.getAgent("remote")).toBeDefined();
|
||||
expect(core.runtimeTransport).toBe("single");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProxiedCopilotRuntimeAgent construction and defaults", () => {
|
||||
it("defaults transport to 'auto' when not specified", () => {
|
||||
const agent = new ProxiedCopilotRuntimeAgent({
|
||||
runtimeUrl: "https://runtime.example/default",
|
||||
agentId: "test-agent",
|
||||
});
|
||||
// The agent should have been created without throwing.
|
||||
// When transport is "auto", the URL is set as REST-style initially.
|
||||
expect(agent).toBeDefined();
|
||||
expect(agent.agentId).toBe("test-agent");
|
||||
});
|
||||
|
||||
it("normalizes trailing slashes on runtimeUrl", () => {
|
||||
const agent = new ProxiedCopilotRuntimeAgent({
|
||||
runtimeUrl: "https://runtime.example/trailing/",
|
||||
agentId: "test-agent",
|
||||
transport: "rest",
|
||||
});
|
||||
expect(agent.runtimeUrl).toBe("https://runtime.example/trailing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentRegistry runtime info requests", () => {
|
||||
const originalFetch = global.fetch;
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
@@ -446,8 +446,11 @@ export class ProxiedCopilotRuntimeAgent extends HttpAgent {
|
||||
headers: { ...headers },
|
||||
...(this.credentials ? { credentials: this.credentials } : {}),
|
||||
});
|
||||
// Only treat a successful (2xx) response as a valid REST runtime.
|
||||
// 404/405 means the endpoint doesn't exist; other non-2xx errors
|
||||
// (500, 403, etc.) should also fall through to single-endpoint.
|
||||
const status = "status" in response ? (response as Response).status : 200;
|
||||
if (status !== 404 && status !== 405) {
|
||||
if (status >= 200 && status < 300) {
|
||||
this.transport = "rest";
|
||||
return (await response.json()) as RuntimeInfo;
|
||||
}
|
||||
|
||||
@@ -388,10 +388,6 @@ export class AgentRegistry {
|
||||
return (await response.json()) as RuntimeInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect transport by trying REST first, then falling back to single-endpoint.
|
||||
* Updates `_runtimeTransport` to the detected value so subsequent requests use it directly.
|
||||
*/
|
||||
/**
|
||||
* Auto-detect transport by trying REST first, then falling back to single-endpoint.
|
||||
* Updates `_runtimeTransport` to the detected value so subsequent requests use it directly.
|
||||
@@ -406,15 +402,15 @@ export class AgentRegistry {
|
||||
headers: { ...headers },
|
||||
...(credentials ? { credentials } : {}),
|
||||
});
|
||||
// If the response indicates a clear failure (404/405), the endpoint
|
||||
// doesn't exist — fall through to the single-endpoint probe.
|
||||
// Only treat a successful (2xx) response as a valid REST runtime.
|
||||
// 404/405 means the endpoint doesn't exist; other non-2xx errors
|
||||
// (500, 403, etc.) should also fall through to single-endpoint.
|
||||
const status = "status" in response ? (response as Response).status : 200;
|
||||
if (status === 404 || status === 405) {
|
||||
// Not a REST runtime — try single-endpoint below
|
||||
} else {
|
||||
if (status >= 200 && status < 300) {
|
||||
this._runtimeTransport = "rest";
|
||||
return (await response.json()) as RuntimeInfo;
|
||||
}
|
||||
// Non-2xx — try single-endpoint below
|
||||
} catch {
|
||||
// REST failed (network error, etc.) — fall through to single-endpoint attempt
|
||||
}
|
||||
|
||||
@@ -573,6 +573,76 @@ describe("CopilotKitProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSingleEndpoint → runtimeTransport mapping", () => {
|
||||
it("maps useSingleEndpoint=true to 'single' transport", () => {
|
||||
const { result } = renderHook(() => useCopilotKit(), {
|
||||
wrapper: ({ children }) => (
|
||||
<CopilotKitProvider useSingleEndpoint={true}>
|
||||
{children}
|
||||
</CopilotKitProvider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current.copilotkit.runtimeTransport).toBe("single");
|
||||
});
|
||||
|
||||
it("maps useSingleEndpoint=false to 'rest' transport", () => {
|
||||
const { result } = renderHook(() => useCopilotKit(), {
|
||||
wrapper: ({ children }) => (
|
||||
<CopilotKitProvider useSingleEndpoint={false}>
|
||||
{children}
|
||||
</CopilotKitProvider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current.copilotkit.runtimeTransport).toBe("rest");
|
||||
});
|
||||
|
||||
it("maps omitted useSingleEndpoint to 'auto' transport", () => {
|
||||
const { result } = renderHook(() => useCopilotKit(), {
|
||||
wrapper: ({ children }) => (
|
||||
<CopilotKitProvider>{children}</CopilotKitProvider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current.copilotkit.runtimeTransport).toBe("auto");
|
||||
});
|
||||
|
||||
it("updates transport when useSingleEndpoint prop changes", () => {
|
||||
let capturedCopilotkit: ReturnType<typeof useCopilotKit>["copilotkit"];
|
||||
|
||||
function Collector({ children }: { children?: React.ReactNode }) {
|
||||
const { copilotkit } = useCopilotKit();
|
||||
capturedCopilotkit = copilotkit;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<CopilotKitProvider useSingleEndpoint={false}>
|
||||
<Collector />
|
||||
</CopilotKitProvider>,
|
||||
);
|
||||
|
||||
expect(capturedCopilotkit!.runtimeTransport).toBe("rest");
|
||||
|
||||
rerender(
|
||||
<CopilotKitProvider useSingleEndpoint={true}>
|
||||
<Collector />
|
||||
</CopilotKitProvider>,
|
||||
);
|
||||
|
||||
expect(capturedCopilotkit!.runtimeTransport).toBe("single");
|
||||
|
||||
rerender(
|
||||
<CopilotKitProvider>
|
||||
<Collector />
|
||||
</CopilotKitProvider>,
|
||||
);
|
||||
|
||||
expect(capturedCopilotkit!.runtimeTransport).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("handles empty arrays for tools", () => {
|
||||
const { result } = renderHook(() => useCopilotKit(), {
|
||||
|
||||
Reference in New Issue
Block a user