5fe5d51b1b
AI Proxy (was AI Router): - server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/ - dashboard_runs.router renamed in place to proxy: an older build on the same DB fails on that column, so dev and prod sharing one DB update together - a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages); Test probes Claude /v1/messages and Codex /v1/responses - Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker Updates: - every tab reloads onto the new UI build when its WebSocket reconnects to a server of another version (lib/newBuildReload.ts, once per version) - Update now stops waiting on a plugin chain that failed or pulled nothing (the old server never goes down), and reloads as soon as a new version answers Also: - a pane whose command exits at once says so (EPANEEXITED) instead of "Command failed: tmux set-option … mouse on" - the pane drag preview draws where the pane actually lands
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
/**
|
|
* @file Tests for the AI Proxy settings page — listing profiles, creating one,
|
|
* and the two rules that keep a key safe: the form never shows one, and an
|
|
* edit that leaves the field blank must not send an empty key that would wipe
|
|
* the stored one.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { AiProxyConfig } from "../AiProxyConfig";
|
|
import { api } from "../../lib/api";
|
|
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
|
|
|
|
vi.mock("../../lib/api", () => ({
|
|
api: {
|
|
aiProxies: {
|
|
list: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
remove: vi.fn(),
|
|
test: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, opts?: Record<string, unknown>) =>
|
|
opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key,
|
|
}),
|
|
}));
|
|
|
|
const native = {
|
|
name: "native",
|
|
label: "Native (Claude / Codex)",
|
|
builtin: true,
|
|
baseUrl: "",
|
|
hasKey: false,
|
|
model: "",
|
|
models: {},
|
|
};
|
|
|
|
const nineRouter = {
|
|
...native,
|
|
name: "9router",
|
|
label: "9Router",
|
|
builtin: false,
|
|
baseUrl: "http://localhost:20128/v1",
|
|
hasKey: true,
|
|
model: "Highest",
|
|
models: {
|
|
opus: "Highest",
|
|
sonnet: "Highest",
|
|
haiku: "Highest",
|
|
smallFast: "Highest",
|
|
subagent: "Highest",
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(api.aiProxies.list)
|
|
.mockReset()
|
|
.mockResolvedValue({ items: [native] });
|
|
vi.mocked(api.aiProxies.create).mockReset().mockResolvedValue(nineRouter);
|
|
vi.mocked(api.aiProxies.update).mockReset().mockResolvedValue(nineRouter);
|
|
vi.mocked(api.aiProxies.remove).mockReset().mockResolvedValue({ removed: "9router" });
|
|
vi.mocked(api.aiProxies.test)
|
|
.mockReset()
|
|
.mockResolvedValue({
|
|
claude: { ok: true, status: 200, message: "Highest" },
|
|
codex: { ok: true, status: 200, message: "Highest" },
|
|
});
|
|
});
|
|
|
|
describe("AiProxyConfig", () => {
|
|
it("lists the built-in native profile with no edit or delete control", async () => {
|
|
render(<AiProxyConfig />);
|
|
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
|
|
expect(screen.queryByLabelText(/aiProxy\.edit Native/)).toBeNull();
|
|
expect(screen.queryByLabelText(/aiProxy\.remove Native/)).toBeNull();
|
|
});
|
|
|
|
it("saves a new profile with the typed endpoint, key and model", async () => {
|
|
render(<AiProxyConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
|
|
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldApiKey"), "sk-typed");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
|
|
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.aiProxies.create).toHaveBeenCalledWith({
|
|
label: "9Router",
|
|
baseUrl: "http://localhost:20128/v1",
|
|
model: "Highest",
|
|
// Every tier equals the headline model, so none is sent: the server
|
|
// fills them, which is what makes one field enough.
|
|
models: {},
|
|
behavesAs: "",
|
|
apiKey: "sk-typed",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("sends the typed behavesAs, which maps the proxy's model names in Claude Code (#56)", async () => {
|
|
render(<AiProxyConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
|
|
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "agent");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldBehavesAs"), "claude-opus-5-5");
|
|
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.aiProxies.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
|
|
)
|
|
);
|
|
});
|
|
|
|
it("sends only the tiers that differ from the headline model", async () => {
|
|
render(<AiProxyConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
|
|
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
|
|
await user.type(screen.getByLabelText("aiProxy.tier.opus"), "PremiumCombo");
|
|
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.aiProxies.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ models: { opus: "PremiumCombo" } })
|
|
)
|
|
);
|
|
});
|
|
|
|
it("never puts a stored key in the edit form, and omits it when left blank", async () => {
|
|
// The API cannot return a key, so an edit that sends `apiKey: ""` would
|
|
// silently erase the one on disk.
|
|
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] });
|
|
render(<AiProxyConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByLabelText("aiProxy.edit 9Router"));
|
|
expect(screen.getByLabelText("aiProxy.fieldApiKey")).toHaveValue("");
|
|
|
|
await user.clear(screen.getByLabelText("aiProxy.fieldLabel"));
|
|
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router nhà");
|
|
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
|
|
|
|
await waitFor(() => expect(api.aiProxies.update).toHaveBeenCalled());
|
|
const call = vi.mocked(api.aiProxies.update).mock.calls[0];
|
|
expect(call?.[1]).not.toHaveProperty("apiKey");
|
|
});
|
|
|
|
it("surfaces a server error instead of closing the form", async () => {
|
|
vi.mocked(api.aiProxies.create).mockRejectedValue(
|
|
new Error("baseUrl must be http:// or https://")
|
|
);
|
|
render(<AiProxyConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
|
|
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Bad");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "file:///etc/passwd");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "x");
|
|
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
|
|
|
|
// Inside the dialog, not behind its scrim.
|
|
const dialog = screen.getByRole("dialog");
|
|
expect(await within(dialog).findByText(/must be http/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("closes the form on Escape", async () => {
|
|
render(<AiProxyConfig />);
|
|
const user = userEvent.setup();
|
|
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
|
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
await user.keyboard("{Escape}");
|
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
});
|
|
});
|
|
|
|
describe("AiProxyConfig — testing an endpoint before saving it", () => {
|
|
it("sends the typed endpoint, key and model, and reports success", async () => {
|
|
render(<AiProxyConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
|
|
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldApiKey"), "sk-typed");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
|
|
await user.click(screen.getByTestId("proxy-test"));
|
|
|
|
await waitFor(() =>
|
|
expect(api.aiProxies.test).toHaveBeenCalledWith({
|
|
baseUrl: "http://localhost:20128/v1",
|
|
model: "Highest",
|
|
apiKey: "sk-typed",
|
|
})
|
|
);
|
|
const lines = await screen.findAllByTestId("proxy-test-result");
|
|
expect(lines[0]).toHaveTextContent("Claude Code: aiProxy.testOk");
|
|
expect(lines[1]).toHaveTextContent("Codex: aiProxy.testOk");
|
|
});
|
|
|
|
it("names the profile being edited so the server can reuse its stored key", async () => {
|
|
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] });
|
|
render(<AiProxyConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByLabelText(/aiProxy\.edit 9Router/));
|
|
await user.click(screen.getByTestId("proxy-test"));
|
|
|
|
await waitFor(() =>
|
|
expect(api.aiProxies.test).toHaveBeenCalledWith({
|
|
baseUrl: "http://localhost:20128/v1",
|
|
model: "Highest",
|
|
name: "9router",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("shows the proxy's own refusal per CLI rather than claiming it works", async () => {
|
|
vi.mocked(api.aiProxies.test).mockResolvedValue({
|
|
claude: { ok: false, status: 401, message: "invalid api key" },
|
|
codex: { ok: true, status: 200, message: "Highest" },
|
|
});
|
|
render(<AiProxyConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
|
|
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
|
|
await user.click(screen.getByTestId("proxy-test"));
|
|
|
|
const [claude, codex] = await screen.findAllByTestId("proxy-test-result");
|
|
expect(claude).toHaveTextContent("Claude Code: aiProxy.testFailed");
|
|
expect(codex).toHaveTextContent("Codex: aiProxy.testOk");
|
|
});
|
|
|
|
it("drops a stale verdict as soon as a field changes", async () => {
|
|
render(<AiProxyConfig />);
|
|
const user = userEvent.setup();
|
|
|
|
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
|
|
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
|
|
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
|
|
await user.click(screen.getByTestId("proxy-test"));
|
|
expect(await screen.findAllByTestId("proxy-test-result")).toHaveLength(2);
|
|
|
|
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "2");
|
|
await waitFor(() => expect(screen.queryAllByTestId("proxy-test-result")).toHaveLength(0));
|
|
});
|
|
|
|
it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => {
|
|
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] });
|
|
let pending: PendingConfirm | null = null;
|
|
const off = subscribeConfirm((p) => (pending = p));
|
|
const user = userEvent.setup();
|
|
render(<AiProxyConfig />);
|
|
const row = (await screen.findByText("9Router")).closest("tr")!;
|
|
|
|
await user.pointer({ keys: "[MouseRight]", target: screen.getByText(native.label) });
|
|
expect(screen.queryByRole("menu")).toBeNull();
|
|
|
|
await user.pointer({ keys: "[MouseRight]", target: row });
|
|
await user.click(screen.getByRole("menuitem", { name: "aiProxy.remove" }));
|
|
await waitFor(() => expect(pending?.title).toBe("aiProxy.removeConfirm:9Router"));
|
|
settleConfirm(false);
|
|
await waitFor(() => expect(pending).toBeNull());
|
|
expect(api.aiProxies.remove).not.toHaveBeenCalled();
|
|
|
|
await user.pointer({ keys: "[MouseRight]", target: row });
|
|
await user.click(screen.getByRole("menuitem", { name: "aiProxy.edit" }));
|
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
off();
|
|
});
|
|
});
|