Files
modelstudioai__cli/packages/commands/tests/config-ui.test.ts
T
2026-08-05 16:58:29 +08:00

273 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import http from "node:http";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vite-plus/test";
import {
activateConfigProfile,
getConfigPath,
makeConfigStore,
writeConfigFile,
readConfigFile,
readConfigProfiles,
} from "bailian-cli-core";
import { createConfigUiServer } from "../src/commands/config/ui.ts";
const TOKEN = "test-token";
interface HttpResult {
status: number;
json: any;
text: string;
}
function httpJson(
port: number,
method: string,
path: string,
opts?: { body?: unknown; headers?: Record<string, string> },
): Promise<HttpResult> {
return new Promise((resolve, reject) => {
const payload = opts?.body !== undefined ? JSON.stringify(opts.body) : undefined;
const headers: Record<string, string> = { ...opts?.headers };
if (payload) headers["Content-Type"] = "application/json";
const req = http.request({ host: "127.0.0.1", port, method, path, headers }, (res) => {
let d = "";
res.on("data", (c) => (d += c));
res.on("end", () => {
let json: unknown = null;
try {
json = d ? JSON.parse(d) : null;
} catch {
json = null;
}
resolve({ status: res.statusCode ?? 0, json, text: d });
});
});
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
/** 隔离临时配置目录 + 启动 UI server跑完清理。 */
async function withServer(fn: (port: number) => Promise<void>): Promise<void> {
const saved = process.env.BAILIAN_CONFIG_DIR;
const dir = mkdtempSync(join(tmpdir(), "bl-ui-"));
process.env.BAILIAN_CONFIG_DIR = dir;
const server = createConfigUiServer(TOKEN, makeConfigStore());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const addr = server.address();
const port = addr && typeof addr === "object" ? addr.port : 0;
try {
await fn(port);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
if (saved === undefined) delete process.env.BAILIAN_CONFIG_DIR;
else process.env.BAILIAN_CONFIG_DIR = saved;
rmSync(dir, { recursive: true, force: true });
}
}
test("GET /api/config 返回全部 profile、明文密钥与持久化激活项", async () => {
await withServer(async (port) => {
await writeConfigFile({ api_key: "sk-default", output: "json" });
await writeConfigFile({ api_key: "sk-dev", access_token: "tok-dev" }, "dev");
await activateConfigProfile("dev");
const res = await httpJson(port, "GET", `/api/config?token=${TOKEN}`);
expect(res.status).toBe(200);
expect(res.json.activeProfile).toBe("dev");
expect(res.json.default).toMatchObject({ api_key: "sk-default", output: "json" });
expect(res.json.named.dev).toMatchObject({ api_key: "sk-dev", access_token: "tok-dev" });
expect(res.json.secretKeys).toContain("api_key");
expect(res.json.keys).toContain("default_image_to_video_model");
expect(res.json.keys).toContain("default_reference_to_video_model");
// Console/telemetry fields are editable via the UI (full ConfigFile surface).
expect(res.json.keys).toContain("console_site");
expect(res.json.keys).toContain("telemetry");
expect(res.json.enums.console_site).toEqual(["domestic", "international"]);
expect(res.json.booleanKeys).toContain("telemetry");
// Default field hints are surfaced as prefilled values in the UI.
expect(res.json.fieldDefaults.default_image_model).toBe("qwen-image-3.0");
expect(res.json.fieldDefaults.default_text_model).toBe("qwen3.8-max");
expect(res.json.fieldDefaults.output_dir).toContain("bailian-output");
expect(res.json.fieldDefaults.timeout).toBe("300");
expect(res.json.fieldDefaults.base_url).toBe("https://dashscope.aliyuncs.com");
// Per-category model catalog (click-to-fill suggestions) is exposed too.
expect(res.json.modelCatalog.default_image_model[0]).toMatchObject({ id: "qwen-image-3.0" });
expect(res.json.modelCatalog.default_video_model.map((m: { id: string }) => m.id)).toContain(
"happyhorse-1.1-i2v",
);
expect(res.json.modelCatalog.default_speech_model.map((m: { id: string }) => m.id)).toContain(
"fun-asr",
);
});
});
test("GET /api/auth/status 无 bridge 时返回未认证login/logout 返回 400", async () => {
await withServer(async (port) => {
// The test harness builds the server without an auth bridge, so the auth
// endpoints degrade safely instead of throwing.
const status = await httpJson(port, "GET", `/api/auth/status?token=${TOKEN}`);
expect(status.status).toBe(200);
expect(status.json.authenticated).toBe(false);
expect(status.json.methods).toEqual({ apiKey: false, console: false, openapi: false });
expect(status.json.primary).toBe(null);
const login = await httpJson(port, "POST", `/api/auth/login?token=${TOKEN}`);
expect(login.status).toBe(400);
const logout = await httpJson(port, "POST", `/api/auth/logout?token=${TOKEN}`);
expect(logout.status).toBe(400);
});
});
test("鉴权:错误 token 401、非 loopback Host 403", async () => {
await withServer(async (port) => {
const bad = await httpJson(port, "GET", `/api/config?token=wrong`);
expect(bad.status).toBe(401);
const badHost = await httpJson(port, "GET", `/api/config?token=${TOKEN}`, {
headers: { Host: "evil.com" },
});
expect(badHost.status).toBe(403);
});
});
test("POST /api/profile 写命名 profiletimeout 强制为 number空串清除键", async () => {
await withServer(async (port) => {
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: {
name: "stage",
data: {
api_key: "sk-stage",
timeout: "90",
base_url: "https://proxy.example.com/team/compatible-mode/v1/?x=1#fragment",
},
},
});
expect(save.status).toBe(200);
expect(readConfigFile("stage")).toMatchObject({
api_key: "sk-stage",
timeout: 90,
base_url: "https://proxy.example.com",
});
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
expect(rawConfig.stage.base_url).toBe("https://proxy.example.com");
// 空串清除 api_key整块替换
const clear = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { api_key: "", timeout: "120" } },
});
expect(clear.status).toBe(200);
const after = readConfigFile("stage");
expect(after.api_key).toBeUndefined();
expect(after.timeout).toBe(120);
});
});
test("POST /api/profile 可编辑 console/telemetry 字段并按类型持久化", async () => {
await withServer(async (port) => {
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: {
name: "stage",
data: {
api_key: "sk-stage",
console_site: "international",
console_region: "ap-southeast-1",
console_switch_agent: "42",
telemetry: "false",
},
},
});
expect(save.status).toBe(200);
const profile = readConfigFile("stage");
expect(profile).toMatchObject({
api_key: "sk-stage",
console_site: "international",
console_region: "ap-southeast-1",
console_switch_agent: 42,
telemetry: false,
});
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
// Coerced to the right JSON types, not left as strings.
expect(rawConfig.stage.console_switch_agent).toBe(42);
expect(rawConfig.stage.telemetry).toBe(false);
// Invalid enum value is rejected.
const bad = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { console_site: "mars" } },
});
expect(bad.status).toBe(400);
expect(String(bad.json.error)).toMatch(/console_site/);
});
});
test("New profile 立即保存空 Profile其他配置读取可以看到", async () => {
await withServer(async (port) => {
const create = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "new-profile", data: {} },
});
expect(create.status).toBe(200);
expect(create.json.saved).toEqual({});
expect(readConfigProfiles().named["new-profile"]).toEqual({});
const list = await httpJson(port, "GET", `/api/config?token=${TOKEN}`);
expect(list.status).toBe(200);
expect(list.json.named["new-profile"]).toEqual({});
});
});
test("POST /api/profile 非法 key 返回 400", async () => {
await withServer(async (port) => {
const res = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { not_a_key: "x" } },
});
expect(res.status).toBe(400);
expect(String(res.json.error)).toMatch(/Invalid config key/);
});
});
test("DELETE /api/profile 删命名 profile缺 name 返回 400", async () => {
await withServer(async (port) => {
await writeConfigFile({ api_key: "sk-stage" }, "stage");
const del = await httpJson(port, "DELETE", `/api/profile?name=stage&token=${TOKEN}`);
expect(del.status).toBe(200);
expect(del.json.deleted).toBe(true);
expect(readConfigProfiles().named.stage).toBeUndefined();
const noName = await httpJson(port, "DELETE", `/api/profile?token=${TOKEN}`);
expect(noName.status).toBe(400);
});
});
test("Save & Activate 创建并激活 Profile删除激活项后切回 default", async () => {
await withServer(async (port) => {
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { api_key: "sk-stage" } },
});
expect(save.status).toBe(200);
const activate = await httpJson(port, "POST", `/api/active?token=${TOKEN}`, {
body: { name: "stage" },
});
expect(activate.status).toBe(200);
expect(activate.json.activeProfile).toBe("stage");
expect(readConfigProfiles().active).toBe("stage");
const missing = await httpJson(port, "POST", `/api/active?token=${TOKEN}`, {
body: { name: "missing" },
});
expect(missing.status).toBe(400);
expect(readConfigProfiles().active).toBe("stage");
const deleted = await httpJson(port, "DELETE", `/api/profile?name=stage&token=${TOKEN}`);
expect(deleted.status).toBe(200);
expect(deleted.json.activeProfile).toBe("default");
expect(readConfigProfiles().active).toBe("default");
});
});