mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
import { expect, test } from "vite-plus/test";
|
|
import type { Identity, Settings } from "../src/index.ts";
|
|
import {
|
|
BailianError,
|
|
ExitCode,
|
|
McpClient,
|
|
callConsoleGateway,
|
|
mapApiError,
|
|
request,
|
|
} from "../src/index.ts";
|
|
import { parseConfigFile } from "../src/config/schema.ts";
|
|
import {
|
|
parseBooleanValue,
|
|
resolveBooleanFlag,
|
|
resolveWatermark,
|
|
} from "../src/utils/boolean-flag.ts";
|
|
|
|
function testDeps(identity: Partial<Identity> = {}): {
|
|
identity: Identity;
|
|
settings: Settings;
|
|
} {
|
|
return {
|
|
identity: {
|
|
binName: "bl",
|
|
version: "0.0.0-test",
|
|
npmPackage: "bailian-cli",
|
|
clientName: "bailian-cli",
|
|
...identity,
|
|
},
|
|
settings: {
|
|
output: "json",
|
|
outputExplicit: true,
|
|
timeout: 30,
|
|
verbose: false,
|
|
quiet: true,
|
|
dryRun: false,
|
|
telemetry: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
test("BailianError carries exitCode and hint", () => {
|
|
const err = new BailianError("nope", ExitCode.AUTH, "do this");
|
|
expect(err.name).toBe("BailianError");
|
|
expect(err.exitCode).toBe(ExitCode.AUTH);
|
|
expect(err.hint).toBe("do this");
|
|
expect(err.toJSON()).toEqual({
|
|
error: { code: ExitCode.AUTH, message: "nope", hint: "do this" },
|
|
});
|
|
});
|
|
|
|
test("mapApiError keeps server message verbatim and surfaces metadata via err.api", () => {
|
|
const err = mapApiError(401, { error: { message: "bad key" } });
|
|
expect(err).toBeInstanceOf(BailianError);
|
|
expect(err.exitCode).toBe(ExitCode.GENERAL);
|
|
expect(err.message).toBe("bad key");
|
|
expect(err.api?.httpStatus).toBe(401);
|
|
expect(err.api?.apiCode).toBeUndefined();
|
|
expect(err.api?.requestId).toBeUndefined();
|
|
});
|
|
|
|
test("mapApiError captures apiCode and request_id when present", () => {
|
|
const err = mapApiError(429, {
|
|
error: { message: "too many", type: "Throttling" },
|
|
request_id: "req-abc-123",
|
|
});
|
|
expect(err.exitCode).toBe(ExitCode.GENERAL);
|
|
expect(err.message).toBe("too many");
|
|
expect(err.api).toEqual({
|
|
httpStatus: 429,
|
|
apiCode: "Throttling",
|
|
requestId: "req-abc-123",
|
|
});
|
|
});
|
|
|
|
test("BailianError propagates cause via options-bag and exposes it in toJSON", () => {
|
|
const root = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), {
|
|
code: "ENOTFOUND",
|
|
});
|
|
const err = new BailianError("Network request failed: ENOTFOUND", ExitCode.NETWORK, "hint", {
|
|
cause: root,
|
|
});
|
|
expect(err.cause).toBe(root);
|
|
expect(err.toJSON()).toEqual({
|
|
error: {
|
|
code: ExitCode.NETWORK,
|
|
message: "Network request failed: ENOTFOUND",
|
|
hint: "hint",
|
|
cause: { message: root.message, code: "ENOTFOUND" },
|
|
},
|
|
});
|
|
});
|
|
|
|
test("toJSON splits service-error metadata into structured fields", () => {
|
|
const err = mapApiError(404, {
|
|
error: {
|
|
message: "The model `qwen3.7` does not exist",
|
|
type: "invalid_request_error",
|
|
},
|
|
request_id: "c55e1acc",
|
|
});
|
|
expect(err.toJSON()).toEqual({
|
|
error: {
|
|
code: ExitCode.GENERAL,
|
|
message: "The model `qwen3.7` does not exist",
|
|
http_status: 404,
|
|
api_code: "invalid_request_error",
|
|
request_id: "c55e1acc",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("callConsoleGateway verbose prints structured request payload", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
const originalWrite = process.stderr.write.bind(process.stderr);
|
|
let stderr = "";
|
|
let requestBody: string | undefined;
|
|
|
|
globalThis.fetch = async (_url, init) => {
|
|
requestBody = init?.body as string | undefined;
|
|
return new Response(JSON.stringify({ data: { success: true, value: "response-body" } }), {
|
|
status: 200,
|
|
statusText: "OK",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
stderr += String(chunk);
|
|
return true;
|
|
}) as typeof process.stderr.write;
|
|
|
|
try {
|
|
await callConsoleGateway(
|
|
{
|
|
region: "ap-southeast-1",
|
|
site: "international",
|
|
switchAgent: 123,
|
|
token: "token",
|
|
},
|
|
30,
|
|
{
|
|
api: "test.api",
|
|
data: { workspaceId: "ws-1", cornerstoneParam: { custom: "value" } },
|
|
},
|
|
{ verbose: true },
|
|
);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
process.stderr.write = originalWrite;
|
|
}
|
|
|
|
expect(requestBody).toBeDefined();
|
|
expect(stderr).toContain('> payload {\n "params": {');
|
|
expect(stderr).toContain(' "region": "ap-southeast-1"');
|
|
expect(stderr).toContain(' "Api": "test.api"');
|
|
expect(stderr).toContain(' "workspaceId": "ws-1"');
|
|
expect(stderr).toContain(' "switchUserType": 3');
|
|
expect(stderr).toContain(' "switchAgent": 123');
|
|
expect(stderr).toContain(' "custom": "value"');
|
|
expect(stderr).toContain("< 200 OK");
|
|
expect(stderr).not.toContain("response-body");
|
|
});
|
|
|
|
test("callConsoleGateway keeps readable message and raw gateway response separately", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
const originalWrite = process.stderr.write.bind(process.stderr);
|
|
const responseBody = {
|
|
data: {
|
|
success: false,
|
|
errorCode: "BailianGateway.Team.NotAuthorised",
|
|
errorMsg: "team not authorised",
|
|
},
|
|
};
|
|
|
|
globalThis.fetch = async () =>
|
|
new Response(JSON.stringify(responseBody), {
|
|
status: 200,
|
|
statusText: "OK",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
|
|
process.stderr.write = (() => true) as typeof process.stderr.write;
|
|
|
|
try {
|
|
await expect(
|
|
callConsoleGateway(
|
|
{ region: "cn-beijing", site: "domestic", token: "token" },
|
|
30,
|
|
{ api: "test.api", data: {} },
|
|
{ verbose: true },
|
|
),
|
|
).rejects.toMatchObject({
|
|
message: "Console gateway error: BailianGateway.Team.NotAuthorised",
|
|
rawResponse: JSON.stringify(responseBody),
|
|
exitCode: ExitCode.GENERAL,
|
|
});
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
process.stderr.write = originalWrite;
|
|
}
|
|
});
|
|
|
|
test("request uses injected client identity for User-Agent", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
let userAgent: string | undefined;
|
|
|
|
globalThis.fetch = async (_url, init) => {
|
|
const headers = init?.headers as Record<string, string> | undefined;
|
|
userAgent = headers?.["User-Agent"];
|
|
return new Response("{}", { status: 200 });
|
|
};
|
|
|
|
try {
|
|
await request(testDeps({ clientName: "test-client", version: "9.8.7" }), {
|
|
url: "https://example.test",
|
|
});
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
expect(userAgent).toBe("test-client/9.8.7");
|
|
});
|
|
|
|
test("request propagates caller AbortSignal to fetch", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
const controller = new AbortController();
|
|
let fetchSignal: AbortSignal | undefined;
|
|
let resolveFetch: ((response: Response) => void) | undefined;
|
|
const fetchStarted = new Promise<void>((resolve) => {
|
|
globalThis.fetch = async (_url, init) => {
|
|
fetchSignal = init?.signal as AbortSignal | undefined;
|
|
resolve();
|
|
return await new Promise<Response>((resolveResponse) => {
|
|
resolveFetch = resolveResponse;
|
|
});
|
|
};
|
|
});
|
|
|
|
const requestPromise = request(testDeps(), {
|
|
url: "https://example.test",
|
|
signal: controller.signal,
|
|
});
|
|
try {
|
|
await fetchStarted;
|
|
expect(fetchSignal).toBeDefined();
|
|
expect(fetchSignal?.aborted).toBe(false);
|
|
controller.abort();
|
|
expect(fetchSignal?.aborted).toBe(true);
|
|
resolveFetch?.(new Response("{}", { status: 200 }));
|
|
await requestPromise;
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test("McpClient uses injected client identity for initialize and User-Agent", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
const bodies: unknown[] = [];
|
|
const userAgents: string[] = [];
|
|
|
|
globalThis.fetch = async (_url, init) => {
|
|
const headers = init?.headers as Record<string, string> | undefined;
|
|
userAgents.push(headers?.["User-Agent"] ?? "");
|
|
const body = init?.body;
|
|
if (typeof body === "string") bodies.push(JSON.parse(body));
|
|
return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: {} }), {
|
|
status: 200,
|
|
});
|
|
};
|
|
|
|
try {
|
|
const client = new McpClient(
|
|
testDeps({ clientName: "test-client", version: "9.8.7" }),
|
|
"https://mcp.example.test",
|
|
"sk-test",
|
|
);
|
|
await client.initialize();
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
expect(userAgents).toEqual(["test-client/9.8.7", "test-client/9.8.7"]);
|
|
expect(bodies[0]).toMatchObject({
|
|
method: "initialize",
|
|
params: { clientInfo: { name: "test-client", version: "9.8.7" } },
|
|
});
|
|
});
|
|
|
|
test("resolveWatermark uses flag or defaults to true", () => {
|
|
expect(resolveWatermark("false")).toBe(false);
|
|
expect(resolveWatermark("true")).toBe(true);
|
|
expect(resolveWatermark(undefined)).toBe(true);
|
|
});
|
|
|
|
test("resolveBooleanFlag uses flag or defaultWhenUnset", () => {
|
|
expect(resolveBooleanFlag("false", true, "prompt-extend")).toBe(false);
|
|
expect(resolveBooleanFlag(undefined, true, "prompt-extend")).toBe(true);
|
|
expect(resolveBooleanFlag(undefined, undefined, "prompt-extend")).toBeUndefined();
|
|
});
|
|
|
|
test("parseBooleanValue accepts only true and false strings (case-insensitive)", () => {
|
|
expect(parseBooleanValue("true")).toBe(true);
|
|
expect(parseBooleanValue("FALSE")).toBe(false);
|
|
expect(() => parseBooleanValue("1")).toThrow(BailianError);
|
|
expect(() => parseBooleanValue("yes")).toThrow(BailianError);
|
|
expect(() => parseBooleanValue("maybe")).toThrow(BailianError);
|
|
});
|
|
|
|
test("parseConfigFile ignores obsolete region field", () => {
|
|
const f = parseConfigFile({ region: "intl" });
|
|
expect("region" in f).toBe(false);
|
|
});
|
|
|
|
test("parseConfigFile accepts only supported languages", () => {
|
|
expect(parseConfigFile({ language: "zh-CN" }).language).toBe("zh-CN");
|
|
expect(parseConfigFile({ language: "fr-FR" }).language).toBeUndefined();
|
|
});
|
|
|
|
test("parseConfigFile accepts only well-formed http(s) base_url", () => {
|
|
expect(parseConfigFile({ base_url: "https://dashscope.aliyuncs.com" }).base_url).toBe(
|
|
"https://dashscope.aliyuncs.com",
|
|
);
|
|
expect(parseConfigFile({ base_url: "http://localhost:8080" }).base_url).toBe(
|
|
"http://localhost:8080",
|
|
);
|
|
expect(
|
|
parseConfigFile({ base_url: "https://proxy.example.com/team/compatible-mode/v1?x=1#y" })
|
|
.base_url,
|
|
).toBe("https://proxy.example.com");
|
|
// Previously accepted because the value merely "starts with http".
|
|
expect(parseConfigFile({ base_url: "httpfoo://evil" }).base_url).toBeUndefined();
|
|
expect(parseConfigFile({ base_url: "not a url" }).base_url).toBeUndefined();
|
|
});
|