mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
1c38c381e5
Align text chat, pipeline, config UI, login validation, and Token Plan text presets, and update README, skill reference, and related tests.
273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
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-2.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-2.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 写命名 profile(timeout 强制为 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");
|
||
});
|
||
});
|