Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/agents/cli-e2e-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
- kscli:`runKscli` from `packages/kscli/tests/e2e/helpers.ts`
- 全量 leaf/group help 使用产品 `commands` 创建 `CommandRegistry`,先通过 `resolve([...path, "--help"])` 检查 help 路由,再用 `captureRegistryHelp` 检查完整 Usage;禁止在 `test.each(commandPaths/groupPaths)` 中逐条启动 `tsx` 子进程
- 真实子进程只保留根 help/version、产品身份、代表性叶子 help/鉴权域和缺参退出码等 shell/stdio/env 契约
- 子进程的 parser 错误和 help 文案遵循 Profile 语言;语义断言应兼容中英文,同时检查参数名和退出码,并使用独立的中英文配置复跑,避免依赖本机 Profile

### 共享

Expand Down
1 change: 1 addition & 0 deletions docs/agents/command-flag-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
### B. 鉴权 / 全局选项

- [ ] 如果是**全局 flag**(所有命令通用),改 `packages/core/src/types/command.ts` 的 `GLOBAL_FLAGS`
- [ ] 全局参数支持完整命令路径前置或后置;命令专属参数和凭证域参数只支持后置,路径各级之间不插入参数。改路由解析时,覆盖根/分组/显式 help 的无效参数校验、前后重复参数、Profile 选择和激活状态不变;纯裸命令与合法分组帮助仍返回 0,参数错误返回 2
- [ ] 如果是凭证域 flag,优先确认是否属于 `MODEL_AUTH_FLAGS` 或 `CONSOLE_AUTH_FLAGS`;不要在单个命令里重复声明
- [ ] 如果新 flag 影响有效配置面,改 `packages/core/src/config/schema.ts` 的 `Settings` 接口
- [ ] 如果对应 env var 或 config 文件字段,改 `packages/core/src/config/loader.ts` 的 `buildSettings`
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/tests/e2e/config-profile.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,112 @@ describe("e2e: named config", () => {
});
});

test.each(["config show", "auth status"])(
"前置和后置 --config 为 %s 选择同一份非激活 Profile,且不修改配置",
async (commandPath) => {
await withTempConfigDir(async (configDir) => {
const originalConfig = {
active_config: "default",
api_key: "sk-default-review",
base_url: "https://default.example.test",
work: {
api_key: "sk-work-review",
base_url: "https://work.example.test",
access_token: "console-work-review",
console_site: "international",
console_region: "ap-southeast-1",
workspace_id: "work-space-review",
output: "json",
},
};
writeConfig(configDir, originalConfig);
const env = {
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
DASHSCOPE_OUTPUT: "",
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
};
const leading = await runCli(["--config", "work", ...commandPath.split(" ")], env);
const trailing = await runCli([...commandPath.split(" "), "--config=work"], env);
expect(leading.exitCode, leading.stderr).toBe(0);
expect(trailing.exitCode, trailing.stderr).toBe(0);
const leadingData = parseStdoutJson<Record<string, unknown>>(leading.stdout);
expect(leadingData).toEqual(parseStdoutJson(trailing.stdout));
expect(leadingData.config).toBe("work");
if (commandPath === "auth status") {
expect(leadingData).toMatchObject({
authenticated: true,
api_key: { source: "config", base_url: "https://work.example.test" },
console: { source: "config", site: "international", region: "ap-southeast-1" },
});
} else {
expect(leadingData.base_url).toBe("https://work.example.test");
expect(leadingData.workspace_id).toBe("work-space-review");
}
expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8"))).toEqual(
originalConfig,
);
});
},
);

test.each([
{ language: "en-US", expectedMessage: /before the command path/, expectedHint: /after/ },
{ language: "zh-CN", expectedMessage: /命令路径之前/, expectedHint: /之后/ },
])(
"前置非全局参数的 JSON 错误遵循选中 Profile 的语言 $language",
async ({ language, expectedMessage, expectedHint }) => {
await withTempConfigDir(async (configDir) => {
writeConfig(configDir, { language: "en-US", work: { language } });
const result = await runCli(
["--config=work", "--output=json", "--prompt", "hi", "text", "chat"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(result.exitCode).toBe(2);
expect(result.stdout).toBe("");
const { error } = JSON.parse(result.stderr) as {
error: { code: number; message: string; hint: string };
};
expect(error.code).toBe(2);
expect(error.message).toMatch(expectedMessage);
expect(error.hint).toMatch(expectedHint);
});
},
);

test.each([
{ argv: ["--config=work", "auth"], help: /Usage: bl auth <command>/ },
{ argv: ["--config=work"], help: /Usage: bl <resource> <command>/ },
])("合法前置参数保留相应帮助页: $argv", async ({ argv, help }) => {
await withTempConfigDir(async (configDir) => {
writeConfig(configDir, { work: { language: "en-US" } });
const result = await runCli(argv, { BAILIAN_CONFIG_DIR: configDir });
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stderr).toMatch(help);
expect(result.stdout).toBe("");
});
});

test.each([
["auth", "--output=json", "--timeout"],
["--output=json", "--timeout", "--help"],
["--config=work", "--output=json", "--timeout", "--version"],
])("中文帮助分支缺少参数值时返回结构化用法错误: %j", async (...argv) => {
await withTempConfigDir(async (configDir) => {
writeConfig(configDir, { language: "zh-CN", work: { language: "zh-CN" } });
const result = await runCli(argv, {
BAILIAN_CONFIG_DIR: configDir,
});
expect(result.exitCode).toBe(2);
expect(result.stdout).toBe("");
expect(JSON.parse(result.stderr)).toMatchObject({
error: { code: 2, message: expect.stringMatching(/--timeout.*需要.*值/) },
});
});
});

test("--config default 等价默认配置", async () => {
await withTempConfigDir(async (dir) => {
writeConfig(dir, {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/tests/e2e/registry.smoke.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe("e2e: bl registry smoke", () => {
test.each(commandPaths)("已注册命令 %s --help 成功", (path) => {
const commandPath = path.split(" ");

expect(resolve([...commandPath, "--help"], registry)).toEqual({
expect(resolve([...commandPath, "--help"], registry)).toMatchObject({
kind: "help",
path: commandPath,
});
Expand All @@ -77,7 +77,7 @@ describe("e2e: bl registry smoke", () => {
test.each(groupPaths)("命令分组 %s --help 成功", (path) => {
const commandPath = path.split(" ");

expect(resolve([...commandPath, "--help"], registry)).toEqual({
expect(resolve([...commandPath, "--help"], registry)).toMatchObject({
kind: "help",
path: commandPath,
});
Expand Down
8 changes: 4 additions & 4 deletions packages/commands/tests/e2e/auth.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,8 +764,8 @@ describe("e2e: auth", () => {
"--base-url",
"https://x.test",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Unknown flag.*--base-url/);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--base-url/);
});

test("auth status 展示 env OpenAPI AK/SK 且不接受 OpenAPI flag 覆盖", async () => {
Expand All @@ -788,8 +788,8 @@ describe("e2e: auth", () => {
expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-placeholder");

const denied = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--access-key-id", "ak"]);
expect(denied.exitCode).not.toBe(0);
expect(denied.stderr).toMatch(/Unknown flag.*--access-key-id/);
expect(denied.exitCode).toBe(2);
expect(denied.stderr).toMatch(/(?:Unknown flag|未知参数).*--access-key-id/);
});

test.skipIf(!isOpenApiE2EReady())(
Expand Down
4 changes: 2 additions & 2 deletions packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ describe("e2e: console global flags (dry-run)", () => {
"--region",
"cn",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Unknown flag.*--region/);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--region/);
});

test("mcp list --dry-run --console-region 透传", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ describe("e2e: managed-agent project init directory defaults", () => {
const directory = await temporaryDirectory();
const removedFlag = await runProject(directory, "build", ["--yes"]);
expect(removedFlag.exitCode).toBe(2);
expect(removedFlag.stderr).toMatch(/Unknown flag.*--yes/);
expect(removedFlag.stderr).toMatch(/(?:Unknown flag|未知参数).*--yes/);
});

test("nested build and validate explain the project root without changing directories", async () => {
Expand Down
20 changes: 11 additions & 9 deletions packages/commands/tests/e2e/managed-agent.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ describe("e2e: managed-agent", () => {
"--refresh-only",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Unknown flag.*--refresh-only/i);
expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--refresh-only/i);
});

test("managed-agent init 不再暴露 Git 仓库脚手架", async () => {
Expand Down Expand Up @@ -538,8 +538,8 @@ agents:
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--name|--model|--instructions|--skill|--skill-dir|--type|--yes/i);
expect(stderr).toMatch(/remote Skill ID/i);
expect(stderr).toMatch(/local Skill directory/i);
expect(stderr).toMatch(/remote Skill ID|远端 Skill ID/i);
expect(stderr).toMatch(/local Skill directory|本地 Skill 目录/i);
expect(stderr).not.toMatch(/--key|--environment|--vault/i);
});

Expand Down Expand Up @@ -577,7 +577,7 @@ agents:
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Unknown option|unknown flag|unexpected/i);
expect(stderr).toMatch(new RegExp(`(?:Unknown flag|未知参数).*${flag}`, "i"));
});

test("managed-agent agent create 将 official Skill ID 写为外部引用", async () => {
Expand Down Expand Up @@ -1355,7 +1355,9 @@ vaults:
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--source must be one of: custom, official/i);
expect(stderr).toMatch(
/--source (?:must be one of: |必须为以下值之一:)custom, official, all/i,
);
});

test("managed-agent skill-list --source all 通过参数校验(缺配置文件时才失败)", async () => {
Expand All @@ -1376,7 +1378,7 @@ vaults:
);
// all 是合法值:不应报 --source 用法错误,而是走到配置加载后因文件缺失退出
expect(exitCode).toBe(2);
expect(stderr).not.toMatch(/--source must be one of/i);
expect(stderr).not.toMatch(/--source (?:must be one of|必须为以下值之一)/i);
expect(stderr).toMatch(/File not found.*agents\.e2e-missing\.yaml/i);
});

Expand Down Expand Up @@ -1419,7 +1421,7 @@ vaults:
"bailian",
]);
expect(providerResult.exitCode).toBe(2);
expect(providerResult.stderr).toMatch(/Unknown flag.*--provider/i);
expect(providerResult.stderr).toMatch(/(?:Unknown flag|未知参数).*--provider/i);

const purposeResult = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
Expand All @@ -1432,7 +1434,7 @@ vaults:
"agent",
]);
expect(purposeResult.exitCode).toBe(2);
expect(purposeResult.stderr).toMatch(/Unknown flag.*--purpose/i);
expect(purposeResult.stderr).toMatch(/(?:Unknown flag|未知参数).*--purpose/i);
});
});

Expand Down Expand Up @@ -1537,7 +1539,7 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () =>
"claude",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Unknown flag.*--provider/i);
expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--provider/i);
});

test("project init --dry-run 不需要密钥且不创建目录", async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/commands/tests/e2e/pipeline.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ describe("e2e: pipeline", () => {
]);
expect(exitCode).toBe(2);
expect(stdout).toBe("");
expect(stderr).toMatch(/--events must be one of: jsonl/i);
expect(stderr).toMatch(/--events (?:must be one of: |必须为以下值之一:)jsonl/i);
});

test("pipeline image/generate dry-run 继承 Profile watermark=false", async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/commands/tests/e2e/quota.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,14 @@ describe("e2e: quota", () => {
"--delete",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("Unknown flag");
expect(stderr).toMatch(/(?:Unknown flag|未知参数).*--delete/);
});

test("quota delete 缺少 --model 报用法错误", async () => {
// 裸 `quota delete`(无任何 flag)会渲染 help 并正常退出,需带 flag 触发必填校验
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "delete", "--yes"]);
expect(exitCode).toBe(2);
expect(stderr).toContain("Missing required flag: --model");
expect(stderr).toMatch(/(?:Missing required flag: |缺少必填参数:)--model/);
});

test("quota delete 无 --yes 返回确认请求 (7)", async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ export {
type ApiKeyLoginKind,
} from "./profile-presets.ts";
export { normalizeModelBaseUrl } from "./model-base-url.ts";
export { validateTimeout } from "./validation.ts";
11 changes: 5 additions & 6 deletions packages/core/src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { detectOutputFormat } from "../output/formatter.ts";
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import type { SourceFlags } from "../types/command.ts";
import { validateTimeout } from "./validation.ts";

const CONFIG_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
const ACTIVE_CONFIG_KEY = "active_config";
Expand Down Expand Up @@ -256,13 +257,11 @@ export function buildSettings(s: ResolutionSources): Settings {
const { flags, file, env } = s;

const envTimeout = env.DASHSCOPE_TIMEOUT ? Number(env.DASHSCOPE_TIMEOUT) : undefined;
const validEnvTimeout =
envTimeout !== undefined && Number.isFinite(envTimeout) && envTimeout > 0
? envTimeout
: undefined;
const validEnvTimeout = validateTimeout(envTimeout) === undefined ? envTimeout : undefined;
const timeout = flags.timeout ?? validEnvTimeout ?? file.timeout ?? 300;
if (!Number.isFinite(timeout) || timeout <= 0) {
throw new BailianError("Timeout must be a positive finite number.", ExitCode.USAGE);
const timeoutError = validateTimeout(timeout);
if (timeoutError) {
throw new BailianError(timeoutError["en-US"], ExitCode.USAGE);
}

return {
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/config/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { LocalizedText } from "../types/command.ts";

const TIMEOUT_ERROR_MESSAGE = {
"en-US": "Timeout must be a positive finite number.",
"zh-CN": "请求超时必须是一个有限的正数。",
} as const satisfies LocalizedText;

/** Shared timeout constraint for effective settings and CLI flag validation. */
export function validateTimeout(timeout: unknown): typeof TIMEOUT_ERROR_MESSAGE | undefined {
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
return TIMEOUT_ERROR_MESSAGE;
}
return undefined;
}
26 changes: 26 additions & 0 deletions packages/core/tests/config-priority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,32 @@ test("timeout:flag > 合法 env > file > 300;非法 env 被跳过;非法 flag
expect(() => resolve({ flags: { timeout: -1 } })).toThrow(/Timeout/);
});

test.each([0, -1, NaN, Infinity, -Infinity])(
"invalid explicit timeout %s is rejected instead of falling back to env or config",
(timeout) => {
expect(() =>
resolve({
flags: { timeout },
env: { DASHSCOPE_TIMEOUT: "20" },
file: { timeout: 30 },
}),
).toThrow(expect.objectContaining({ exitCode: 2 }));
},
);

test.each(["0", "-1", "NaN", "Infinity", "-Infinity"])(
"invalid environment timeout %s falls back to the selected config",
(environmentTimeout) => {
expect(
resolve({ env: { DASHSCOPE_TIMEOUT: environmentTimeout }, file: { timeout: 30 } }).timeout,
).toBe(30);
},
);

test("positive fractional timeouts remain valid", () => {
expect(resolve({ flags: { timeout: 0.1 } }).timeout).toBe(0.1);
});

test("workspaceId:flag > env > file(console 域 flag)", () => {
const file: ConfigFile = { workspace_id: "ws-file" };
const env = { BAILIAN_WORKSPACE_ID: "ws-env" };
Expand Down
4 changes: 2 additions & 2 deletions packages/kscli/tests/e2e/registry.smoke.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe("e2e: kscli registry smoke", () => {
test.each(commandPaths)("已注册命令 %s --help 成功", (path) => {
const commandPath = path.split(" ");

expect(resolve([...commandPath, "--help"], registry)).toEqual({
expect(resolve([...commandPath, "--help"], registry)).toMatchObject({
kind: "help",
path: commandPath,
});
Expand All @@ -53,7 +53,7 @@ describe("e2e: kscli registry smoke", () => {
test.each(groupPaths)("命令分组 %s --help 成功", (path) => {
const commandPath = path.split(" ");

expect(resolve([...commandPath, "--help"], registry)).toEqual({
expect(resolve([...commandPath, "--help"], registry)).toMatchObject({
kind: "help",
path: commandPath,
});
Expand Down
Loading
Loading