diff --git a/docs/agents/cli-e2e-tests.md b/docs/agents/cli-e2e-tests.md index 055dd69..749329c 100644 --- a/docs/agents/cli-e2e-tests.md +++ b/docs/agents/cli-e2e-tests.md @@ -1,31 +1,58 @@ # CLI E2E 测试规范 +## 架构分层 + +| 层级 | 路径 | 测什么 | +| --------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| **共享基建** | `packages/e2e` | gating、子进程 runner、output、globalSetup(`private`,不发布) | +| **命令 E2E** | `packages/commands/tests/e2e` | help、缺参、dry-run、live(gated);每用例最小路由 | +| **bl smoke** | `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` | 产品 map 全部 path `--help`、分组 help、根 help | +| **kscli smoke** | `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` | 从 `kscli/src/commands.ts` 推导 path/分组;identity(`--version`、`search --help` path) | +| **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 | + +**依赖边界**:`e2e` → `core`;`commands/tests` → `e2e` + `commands/src`;产品 tests → `e2e` + 各自 `src`。**禁止**产品 import `commands/tests/**`(子进程 spawn harness 路径除外)。 + ## 触发条件 - 新增/修改 `packages/commands/src/commands` 下的 command 实现 - 新增/修改 `packages/cli/src/commands.ts` 的 `bl` 命令路径 map -- 新建或扩展 `packages/cli/tests/e2e/*.e2e.test.ts` 用例 -- 为命令补 help / 缺参 / dry-run / 真实集成测试 +- 新建或扩展 `packages/commands/tests/e2e/.e2e.test.ts` +- 新增 bl 产品 path → `registry.smoke` 自动覆盖 leaf path;commands topic 测试在 `topic-routes.ts` 补最小路由 + +跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`。 -以上情况必须同步维护 `packages/cli/tests/e2e/.e2e.test.ts`。跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`。 +> **规则**:共享 command 行为在 `commands/tests/e2e`;产品 map、identity、CLI-only 命令留在对应产品 `tests/e2e`。 ## 文件与工具 -- 路径:`packages/cli/tests/e2e/.e2e.test.ts` -- 框架:`vite-plus/test`;子进程跑 CLI:`runCli` from `./helpers.ts` +### commands E2E + +- 路径:`packages/commands/tests/e2e/.e2e.test.ts` +- 子进程:`runCommandE2e(routes, args)` from `./helpers.ts`(spawn `harness/main.ts`,`routes` 为本 topic 最小 path → export 映射) +- fixtures:`packages/commands/tests/e2e/fixtures/` +- 路由常量:`topic-routes.ts`(按 topic 维护,**非**全量产品 map) + +### 产品 smoke + +- bl:`runCli` from `packages/cli/tests/e2e/helpers.ts` +- kscli:`runKscli` from `packages/kscli/tests/e2e/helpers.ts` + +### 共享 + +- gating / output / runner:`e2e/gating`、`e2e/output`、`e2e/runner` +- globalSetup:根 `vite.config.ts` → `packages/e2e/src/global-setup.ts` - 解析 JSON stdout:`parseStdoutJson`;输出目录:`makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url))` - 长任务:`cliTimeoutPrefix()`;视频用例加 `test(..., 3_600_000)` 等显式超时 ## 双层 describe(固定结构) ```ts -// 1) 不 skip:分组 + --help,无密钥、无真实 API +// 1) 不 skip:--help,无密钥、无真实 API(分组 help 由 bl registry.smoke 覆盖) describe("e2e: ", () => { - test(" 分组展示子命令帮助且成功退出", ...); test(" --help 正常退出", ...); }); -// 2) skipIf:缺参 / dry-run / 真实集成;原有集成用例放最后、勿改逻辑 +// 2) skipIf:缺参 / dry-run / 真实集成 describe.skipIf()("e2e: (DashScope …)", () => { test("缺少 -- 时退出为用法错误 (2)", ...); test(" --dry-run ...", ...); // 若适用 @@ -33,23 +60,27 @@ describe.skipIf()("e2e: (DashScope …)", () => { }); ``` -## skip 条件(helpers.ts) +## skip 条件(`e2e/gating`,commands helpers re-export) -| 场景 | 条件 | -| ------------------- | ----------------------------------------------------- | -| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` | -| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` | -| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` | -| 知识库 | `isKnowledgeE2EReady()` | -| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` | +| 场景 | 条件 | +| ----------------------- | ---------------------------------------------------------------------------------------------------------- | +| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` | +| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` | +| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` | +| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` | +| 知识库 chat/search live | `isChatE2EReady()` / `isSearchE2EReady()`(`knowledge chat/search`,需 `BAILIAN_WORKSPACE_ID` + agent ID) | ## 用例类型 -1. **分组 help**:`runCli(["image"])` → `exitCode === 0`,stdout+stderr 含子命令名 -2. **--help**:`runCli([..., "--help"])` → stderr 含主要 flags -3. **缺参**:带一个无害全局 flag(如 `--quiet`)且不传 required flag → `exitCode === 2`,stderr 匹配 `--flag|Missing required argument` -4. **--dry-run**:仅当实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本,不入网 -5. **真实集成**:保留既有用例名称与断言;放在 skip 块**末尾** +1. **--help**:`runCommandE2e(ROUTES, [..., "--help"])` → stderr 含主要 flags +2. **缺参**:带无害全局 flag(如 `--quiet`)且不传 required flag → `exitCode === 2` +3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本 +4. **真实集成**:放在 skip 块**末尾** + +## 增删命令同步 + +- **commands export** + **topic 路由**(`topic-routes.ts` 或测试文件内 `ROUTES`)+ **产品 map**(`cli/commands.ts` / `kscli/commands.ts`) +- 分组 help 由产品 `registry.smoke` 负责,无需在 commands 重复 ## 安全与例外 @@ -60,24 +91,36 @@ describe.skipIf()("e2e: (DashScope …)", () => { ## 新增 command 检查清单 -- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `tests/e2e/.e2e.test.ts`(新建或扩展) +- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `topic-routes.ts` 补最小路由 +- [ ] `packages/commands/tests/e2e/.e2e.test.ts`(新建或扩展) - [ ] 若改了 `usageArgs` / `flags` / `exampleArgs`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/bailian-cli/reference/` 并提交 -- [ ] 顶层:分组 help + 子命令 `--help`(多子命令则各一条 help) +- [ ] 子命令 `--help`(分组 help 由 bl `registry.smoke` 覆盖) - [ ] skip 块:每个 required flag 缺参;可 dry-run 则加一条 - [ ] 至少一条真实集成(或说明为何仅 smoke);不破坏已有集成用例顺序 -- [ ] `pnpm test packages/cli/tests/e2e/` 通过 +- [ ] `vp test packages/commands/tests/e2e/` 通过 + +## 调试命令 + +```sh +pnpm --filter bailian-cli-commands exec vp test packages/commands/tests/e2e/text-chat.e2e.test.ts +pnpm --filter bailian-cli exec vp test packages/cli/tests/e2e/registry.smoke.e2e.test.ts +pnpm --filter knowledge-studio-cli exec vp test packages/kscli/tests/e2e/registry.smoke.e2e.test.ts +pnpm --filter bailian-cli-runtime exec vp test packages/runtime/tests/proxy.e2e.test.ts +``` ## 示例片段 ```ts +import { FOO_ROUTES } from "./topic-routes.ts"; + test("foo bar 缺少 --prompt 时退出为用法错误 (2)", async () => { - const { stderr, exitCode } = await runCli(["foo", "bar", "--quiet"]); + const { stderr, exitCode } = await runCommandE2e(FOO_ROUTES, ["foo", "bar", "--quiet"]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--prompt|Missing required argument/i); }); test("foo bar --dry-run 仅输出计划", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(FOO_ROUTES, [ "foo", "bar", "--dry-run", @@ -97,4 +140,4 @@ test("foo bar --dry-run 仅输出计划", async () => { - **E2E**:单条/少量调用、断言固定、可进 `vp test`(见上文 skip 条件) - **批量压测**:`packages/cli/tests/stress/run.mjs` + `targets/*.mjs`,并发 + 报告,**仅手动** `pnpm run test:stress -- ` -勿把压测并入 E2E 或默认 CI。详见 [stress-batch-tests.md](stress-batch-tests.md)。 +勿把压测并入 E2E 或默认 CI。详见 [stress-batch-tests.md](stress-batch-tests.md). diff --git a/docs/agents/command-add-remove.md b/docs/agents/command-add-remove.md index b5d8e35..fb333c1 100644 --- a/docs/agents/command-add-remove.md +++ b/docs/agents/command-add-remove.md @@ -92,15 +92,17 @@ packages/commands/src/index.ts ### D. 测试层 -- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/cli/tests/e2e/.e2e.test.ts` -- [ ] 删除命令时一并删对应 e2e / README 示例 / reference 生成结果 -- [ ] 如果 shared command 在不同入口路径下复用,至少确保 `bl` 入口 e2e 覆盖;`kscli` 入口改动需补对应入口测试或手工 smoke +- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/commands/tests/e2e/.e2e.test.ts` +- [ ] 同步 `packages/commands/tests/e2e/topic-routes.ts`(该 topic 的最小 path → export 映射) +- [ ] bl 产品 path 变更由 `registry.smoke` 自动覆盖;kscli 变更同步 `kscli/src/commands.ts` 与 `registry.smoke` +- [ ] 删除命令时一并删对应 commands e2e / README 示例 / reference / topic 路由条目 +- [ ] 如果 shared command 在不同入口路径下复用,至少确保 commands e2e 覆盖 `bl` path;`kscli` 入口改动需补对应 smoke 或说明不测 flat path live ### E. 重命名特殊处理 - [ ] 全仓 grep **旧命令名字符串**,确保以下位置全部更新: - `packages/cli/src/commands.ts` map key - - `packages/kscli/src/main.ts` map key(如适用) + - `packages/kscli/src/commands.ts` map key(如适用) - 用户可见 hint / README / tests - `skills/bailian-cli/reference/`(重建后检查并提交) - [ ] 检查 `usageArgs` / `exampleArgs` 没有硬编码旧的 `bl ` 前缀 @@ -111,7 +113,7 @@ packages/commands/src/index.ts pnpm run sync:skill-assets pnpm -F bailian-cli exec tsx src/main.ts --help pnpm -F bailian-cli exec tsx src/main.ts -vp test packages/cli/tests/e2e/.e2e.test.ts +vp test packages/commands/tests/e2e/.e2e.test.ts ``` 如改了 `kscli` 入口: diff --git a/packages/cli/package.json b/packages/cli/package.json index 36888c6..a7b4932 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -59,6 +59,7 @@ "ajv": "catalog:", "boxen": "catalog:", "chalk": "catalog:", + "e2e": "workspace:*", "typescript": "^6.0.2", "undici": "catalog:", "vite-plus": "0.1.22", diff --git a/packages/cli/tests/e2e/helpers.ts b/packages/cli/tests/e2e/helpers.ts index 8f28d83..8d42da7 100644 --- a/packages/cli/tests/e2e/helpers.ts +++ b/packages/cli/tests/e2e/helpers.ts @@ -1,215 +1,52 @@ -import { execFile } from "child_process"; -import { mkdirSync, readFileSync } from "fs"; -import { promisify } from "util"; -import { basename, dirname, join } from "path"; +import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import { readConfigFile } from "bailian-cli-core"; - -const execFileAsync = promisify(execFile); - -/** - * Vitest `global-setup.ts` 写入 `test/output/` 下本文件名,供各 worker 进程读取同一会话 id。 - * (仅模块内变量无法跨 Vitest 多进程 worker 共享。) - */ -export const E2E_RUN_SESSION_FILENAME = ".e2e-run-session"; - -/** - * 单次 `vp test` / Vitest 运行共用的 E2E 输出会话目录名(惰性缓存于当前进程)。 - */ -let e2eOutputSessionId: string | undefined; - -/** `packages/cli` 根目录(含 `src/main.ts`) */ +import { + cliTimeoutPrefix, + cliTimeoutSeconds, + e2eLabelFromMetaUrl, + isConsoleAuthFailure, + makeE2eOutputDir, + parseStdoutJson, +} from "e2e/output"; +import { runNodeMain, type RunCliResult } from "e2e/runner"; +import { + isBailianE2EEnabled, + isBailianE2EMediaEnabled, + isBailianE2EVideoEnabled, + isChatE2EReady, + isConsoleE2EReady, + isDashScopeE2EReady, + isSearchE2EReady, +} from "e2e/gating"; +import { monorepoRoot } from "e2e/monorepo-root"; + +export { + cliTimeoutPrefix, + cliTimeoutSeconds, + e2eLabelFromMetaUrl, + isBailianE2EEnabled, + isBailianE2EMediaEnabled, + isBailianE2EVideoEnabled, + isChatE2EReady, + isConsoleAuthFailure, + isConsoleE2EReady, + isDashScopeE2EReady, + isSearchE2EReady, + makeE2eOutputDir, + monorepoRoot, + parseStdoutJson, +}; +export type { RunCliResult }; + +/** `packages/cli` 根目录 */ export const cliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const mainTs = join(cliPackageRoot, "src", "main.ts"); -/** Monorepo 根(含根 `package.json`) */ -export function monorepoRoot(): string { - return join(cliPackageRoot, "..", ".."); -} - -export function localBin(name: string): string { - return join( - monorepoRoot(), - "node_modules", - ".bin", - process.platform === "win32" ? `${name}.cmd` : name, - ); -} - -function readE2eRunSessionFromOutputDir(): string | undefined { - try { - const p = join(monorepoRoot(), "test", "output", E2E_RUN_SESSION_FILENAME); - const t = readFileSync(p, "utf8").trim(); - return t.length > 0 ? t : undefined; - } catch { - return undefined; - } -} - -function getE2eOutputSessionId(): string { - if (!e2eOutputSessionId) { - const fromEnv = process.env.BAILIAN_E2E_RUN_ID?.trim(); - if (fromEnv) { - e2eOutputSessionId = fromEnv.replace(/[^a-zA-Z0-9._-]+/g, "-"); - } else { - const fromFile = readE2eRunSessionFromOutputDir(); - if (fromFile) { - e2eOutputSessionId = fromFile.replace(/[^a-zA-Z0-9._-]+/g, "-"); - } else { - e2eOutputSessionId = `e2e-run-${Date.now()}-${process.pid}`; - } - } - } - return e2eOutputSessionId; -} - -/** - * 在 `test/output/<会话>/` 下创建用例子目录。 - * 会话 id 优先 `BAILIAN_E2E_RUN_ID`,否则读 Vitest globalSetup 写入的 `test/output/.e2e-run-session`, - * 再否则回退为单进程 id(非 Vitest 直接跑用例时)。 - * 若已设 `BAILIAN_E2E_OUT` 则直接使用(不再套会话目录)。 - */ -export function makeE2eOutputDir(label: string): string { - const fromEnv = process.env.BAILIAN_E2E_OUT?.trim(); - if (fromEnv) { - mkdirSync(fromEnv, { recursive: true }); - return fromEnv; - } - const safe = label.replace(/[^a-zA-Z0-9._-]+/g, "-"); - const sessionDir = join(monorepoRoot(), "test", "output", getE2eOutputSessionId()); - mkdirSync(sessionDir, { recursive: true }); - const dir = join(sessionDir, `e2e-vp-${safe}-${Date.now()}`); - mkdirSync(dir, { recursive: true }); - return dir; -} - -/** 全局 `--timeout` 秒数(视频等长任务) */ -export function cliTimeoutSeconds(): string { - return process.env.BAILIAN_E2E_TIMEOUT_SEC?.trim() || "3600"; -} - -export function cliTimeoutPrefix(): string[] { - return ["--timeout", cliTimeoutSeconds()]; -} - -/** 显式开启后才跑真实网络 E2E,避免默认 `vp test` 依赖密钥或打外网 */ -export function isBailianE2EEnabled(): boolean { - return process.env.BAILIAN_E2E === "1"; -} - -/** 可调 DashScope 的 API Key:环境变量优先,否则读 ~/.bailian/config.json */ -export function isDashScopeE2EReady(): boolean { - if (!isBailianE2EEnabled()) return false; - if (process.env.DASHSCOPE_API_KEY?.trim()) return true; - try { - const f = readConfigFile(); - return typeof f.api_key === "string" && f.api_key.length > 0; - } catch { - return false; - } -} - -/** - * Console-gateway 命令(quota / usage free / usage stats)的 E2E 就绪检查: - * 需 `BAILIAN_E2E=1` 且存在 console access_token(`~/.bailian/config.json` 的 - * `access_token`;凭证解析已集中到 authStage,不再读环境变量)。 - * - * 仅检查 token 是否存在——无法本地判断是否过期。token 过期时 gated 用例仍会执行, - * 但用 `isConsoleAuthFailure` 把“session 未登录/已过期”的优雅报错视为通过,保持 - * 与 deploy/dataset “无 key / 有效 key / 失效 key 均绿”的一致策略。 - */ -export function isConsoleE2EReady(): boolean { - if (!isBailianE2EEnabled()) return false; - try { - const config = readConfigFile(); - return typeof config.access_token === "string" && config.access_token.length > 0; - } catch { - return false; - } -} - -/** 语音与图像(可设 `BAILIAN_E2E_MEDIA=0` 在仅跑文本/记忆/知识库时跳过) */ -export function isBailianE2EMediaEnabled(): boolean { - if (process.env.BAILIAN_E2E_MEDIA === "0") return false; - return isBailianE2EEnabled(); -} - -/** 文生视频 / 图生视频 / 参考视频 / 视频编辑(耗时长,默认关闭) */ -export function isBailianE2EVideoEnabled(): boolean { - return isBailianE2EEnabled() && process.env.BAILIAN_E2E_VIDEO === "1"; -} - -/** 从 `import.meta.url` 生成 OUT 子目录标签,避免并行用例目录冲突 */ -export function e2eLabelFromMetaUrl(metaUrl: string): string { - return basename(fileURLToPath(metaUrl), ".ts").replace(/\.e2e\.test$/, ""); -} - -/** 知识库用例:须显式索引 ID + API-KEY */ -export function isKnowledgeE2EReady(): boolean { - if (!isBailianE2EEnabled()) return false; - if (!process.env.BAILIAN_E2E_INDEX_ID) return false; - return isDashScopeE2EReady(); -} - -export interface RunCliResult { - stdout: string; - stderr: string; - exitCode: number; -} - -/** - * 子进程执行 CLI(等价于在 `packages/cli` 下 `tsx src/main.ts ...`)。 - * request_id 等诊断信息在 stderr;`--output json` 时 JSON 在 stdout。 - */ +/** 子进程执行 bl CLI */ export async function runCli( args: string[], envOverrides: NodeJS.ProcessEnv = {}, ): Promise { - try { - const { stdout, stderr } = await execFileAsync(localBin("tsx"), [mainTs, ...args], { - cwd: cliPackageRoot, - encoding: "utf8", - maxBuffer: 32 * 1024 * 1024, - env: { - ...process.env, - NODE_NO_WARNINGS: "1", - DO_NOT_TRACK: "1", - ...envOverrides, - }, - }); - return { stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 }; - } catch (err: unknown) { - const e = err as { - stdout?: string; - stderr?: string; - code?: number; - }; - return { - stdout: e.stdout ?? "", - stderr: e.stderr ?? "", - exitCode: typeof e.code === "number" ? e.code : 1, - }; - } -} - -export function parseStdoutJson(stdout: string): T { - const t = stdout.trim(); - // Extract JSON object — stdout may contain [perf] console.time lines before JSON - const jsonMatch = t.match(/\{[\s\S]*\}/); - if (!jsonMatch) throw new Error(`No JSON object found in stdout: ${t.slice(0, 200)}`); - return JSON.parse(jsonMatch[0]) as T; -} - -/** - * 判断一次 CLI 运行是否因 console session 未登录/已过期而失败。 - * - * Console E2E 用例的 readiness 闸(`isConsoleE2EReady`)只能判断 token 是否存在, - * 无法判断是否过期;token 失效时 gated 用例仍会执行并拿到鉴权错误。本函数让用例 - * 参考 deploy/dataset 的做法:只要 CLI 把鉴权错误优雅上抛(非零退出 + stderr 说明 - * session 失效),即视为通过,而不是强求 exit 0 的成功输出。 - */ -export function isConsoleAuthFailure(result: RunCliResult): boolean { - if (result.exitCode === 0) return false; - return /not logged in|has expired|NotLogined|Run `bl auth login/i.test(result.stderr); + return runNodeMain(mainTs, args, { cwd: cliPackageRoot, env: envOverrides }); } diff --git a/packages/cli/tests/e2e/knowledge-search.e2e.test.ts b/packages/cli/tests/e2e/knowledge-search.e2e.test.ts deleted file mode 100644 index fee03f6..0000000 --- a/packages/cli/tests/e2e/knowledge-search.e2e.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCli } from "./helpers.ts"; - -interface DryRunBody { - endpoint?: string; - request?: { - query?: string; - agent_id?: string; - images?: string[]; - query_history?: Array<{ role: string; content: string }>; - }; -} - -describe("e2e: knowledge search", () => { - test("knowledge search --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["knowledge", "search", "--help"]); - expect(exitCode, stderr).toBe(0); - expect(stderr).toMatch(/--query/i); - expect(stderr).toMatch(/--agent-id/i); - expect(stderr).toMatch(/--workspace-id/i); - expect(stderr).toMatch(/--image/i); - expect(stderr).toMatch(/--query-history/i); - }); - - test("缺少 --query 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["knowledge", "search", "--agent-id", "aid_test"]); - expect(exitCode).toBe(2); - expect(stderr).toMatch(/--query|Usage:/i); - }); - - test("缺少 --agent-id 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["knowledge", "search", "--query", "test"]); - expect(exitCode).toBe(2); - expect(stderr).toMatch(/--agent-id|Usage:/i); - }); - - test("缺少 --workspace-id 时非零退出并提示", async () => { - const { stderr, exitCode } = await runCli( - // 假 key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入 - [ - "knowledge", - "search", - "--query", - "test", - "--agent-id", - "aid_test", - "--api-key", - "sk-fake", - "--output", - "json", - ], - { BAILIAN_WORKSPACE_ID: "", BAILIAN_CONFIG_DIR: "/tmp" }, - ); - expect(exitCode).not.toBe(0); - expect(stderr).toMatch(/workspace.*required/i); - }); - - test("--dry-run 输出 endpoint 和 request body", async () => { - const { stdout, stderr, exitCode } = await runCli([ - "knowledge", - "search", - "--dry-run", - "--query", - "什么是RAG", - "--agent-id", - "aid_test", - "--workspace-id", - "ws_test", - "--output", - "json", - ]); - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson(stdout); - expect(data.endpoint).toMatch(/ws_test\.cn-beijing\.maas\.aliyuncs\.com/); - expect(data.endpoint).toMatch(/api\/v1\/indices\/knowledge\/search/); - expect(data.request?.query).toBe("什么是RAG"); - expect(data.request?.agent_id).toBe("aid_test"); - }); - - test("--dry-run + --image 输出 images", async () => { - const { stdout, stderr, exitCode } = await runCli([ - "knowledge", - "search", - "--dry-run", - "--query", - "test", - "--agent-id", - "aid_test", - "--workspace-id", - "ws_test", - "--image", - "https://example.com/a.jpg", - "--image", - "https://example.com/b.jpg", - "--output", - "json", - ]); - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson(stdout); - expect(data.request?.images).toEqual([ - "https://example.com/a.jpg", - "https://example.com/b.jpg", - ]); - }); - - test("--dry-run + --query-history 输出用户对话历史", async () => { - const { stdout, stderr, exitCode } = await runCli([ - "knowledge", - "search", - "--dry-run", - "--query", - "它怎么工作", - "--agent-id", - "aid_test", - "--workspace-id", - "ws_test", - "--query-history", - '[{"role":"user","content":"什么是RAG"},{"role":"assistant","content":"RAG是检索增强生成"}]', - "--output", - "json", - ]); - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson(stdout); - expect(data.request?.query_history).toEqual([ - { role: "user", content: "什么是RAG" }, - { role: "assistant", content: "RAG是检索增强生成" }, - ]); - }); - - test("--dry-run + --query-history 无效 JSON 非零退出", async () => { - const { stderr, exitCode } = await runCli([ - "knowledge", - "search", - "--dry-run", - "--query", - "test", - "--agent-id", - "aid_test", - "--workspace-id", - "ws_test", - "--query-history", - "not-valid-json", - "--output", - "json", - ]); - expect(exitCode).not.toBe(0); - expect(stderr).toMatch(/query-history.*valid JSON/i); - }); -}); diff --git a/packages/cli/tests/e2e/registry.smoke.e2e.test.ts b/packages/cli/tests/e2e/registry.smoke.e2e.test.ts new file mode 100644 index 0000000..abcb2b0 --- /dev/null +++ b/packages/cli/tests/e2e/registry.smoke.e2e.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "vite-plus/test"; +import { deriveGroupPaths } from "e2e/registry-smoke"; +import { commands } from "../../src/commands.ts"; +import { runCli } from "./helpers.ts"; + +const commandPaths = Object.keys(commands).sort(); +const groupPaths = deriveGroupPaths(commandPaths); + +describe("e2e: bl registry smoke", () => { + test("根帮助展示 bl 与全局 flag", async () => { + const { stderr, exitCode } = await runCli(["--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/\bbl\b/i); + expect(stderr).toMatch(/--base-url/); + expect(stderr).toMatch(/--console-region/); + expect(stderr).toMatch(/--console-site/); + expect(stderr).toMatch(/--console-switch-agent/); + expect(stderr).not.toMatch(/^\s*--region\s/m); + }); + + test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => { + const { stderr, exitCode } = await runCli(["quota", "check", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/Global Flags:/); + expect(stderr).toMatch(/--console-region /); + expect(stderr).toMatch(/--model /); + expect(stderr).toMatch(/--period /); + expect(stderr).toMatch(/--output /); + expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/); + }); + + test.each(commandPaths)("已注册命令 %s --help 成功", async (path) => { + const { stderr, exitCode } = await runCli([...path.split(" "), "--help"]); + expect(exitCode, stderr).toBe(0); + }); + + test.each(groupPaths)("命令分组 %s --help 成功", async (path) => { + const { stderr, exitCode } = await runCli([...path.split(" "), "--help"]); + expect(exitCode, stderr).toBe(0); + }); +}); diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index 769e51e..dc315e3 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ test: { - globalSetup: "./tests/e2e/global-setup.ts", + globalSetup: "../e2e/src/global-setup.ts", testTimeout: 60_000, hookTimeout: 60_000, }, diff --git a/packages/commands/package.json b/packages/commands/package.json index a8cbfbf..9999ad7 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -49,6 +49,7 @@ "devDependencies": { "@types/node": "catalog:", "@typescript/native-preview": "7.0.0-dev.20260328.1", + "e2e": "workspace:*", "typescript": "^6.0.2", "vite-plus": "0.1.22" }, diff --git a/packages/cli/tests/e2e/advisor-recommend.e2e.test.ts b/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts similarity index 87% rename from packages/cli/tests/e2e/advisor-recommend.e2e.test.ts rename to packages/commands/tests/e2e/advisor-recommend.e2e.test.ts index 201e036..ec2fb2b 100644 --- a/packages/cli/tests/e2e/advisor-recommend.e2e.test.ts +++ b/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts @@ -1,15 +1,14 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { ADVISOR_ROUTES } from "./topic-routes.ts"; describe("e2e: advisor recommend", () => { - test("advisor shows subcommand groups and exits successfully", async () => { - const { stdout, stderr, exitCode } = await runCli(["advisor"]); - expect(exitCode, stderr).toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/advisor|recommend/i); - }); - test("advisor recommend --help exits successfully", async () => { - const { stderr, exitCode } = await runCli(["advisor", "recommend", "--help"]); + const { stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ + "advisor", + "recommend", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/recommend|--message|dry-run/i); }); @@ -17,13 +16,17 @@ describe("e2e: advisor recommend", () => { describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () => { test("advisor recommend without --message errors as usage error (2)", async () => { - const { stdout, stderr, exitCode } = await runCli(["advisor", "recommend", "--quiet"]); + const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ + "advisor", + "recommend", + "--quiet", + ]); expect(exitCode).toBe(2); expect(`${stdout}\n${stderr}`).toMatch(/--message|Usage:/i); }); test("advisor recommend --dry-run outputs intent analysis and candidates", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ "advisor", "recommend", "--dry-run", @@ -63,7 +66,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () }, 60_000); test("advisor recommend full flow returns results", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ "advisor", "recommend", "--message", @@ -96,7 +99,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () // ---- Model preference: positive cases ---- test("scoped preference — intent contains modelPreference.mode=scoped when family is specified", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ "advisor", "recommend", "--dry-run", @@ -116,7 +119,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () }, 60_000); test("comparison preference — intent contains modelPreference.mode=comparison when comparing models", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ "advisor", "recommend", "--dry-run", @@ -136,7 +139,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () }, 60_000); test("excludes preference — intent detects modelPreference when excluding models", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ "advisor", "recommend", "--dry-run", @@ -166,7 +169,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () // ---- Model preference: negative cases ---- test("no preference — intent has no modelPreference or mode=unconstrained for generic queries", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ "advisor", "recommend", "--dry-run", diff --git a/packages/cli/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts similarity index 73% rename from packages/cli/tests/e2e/auth.e2e.test.ts rename to packages/commands/tests/e2e/auth.e2e.test.ts index ee42fe0..6e8a2a4 100644 --- a/packages/cli/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -1,48 +1,29 @@ import { readFileSync } from "fs"; import { join } from "path"; import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, runCli } from "./helpers.ts"; +import { + isDashScopeE2EReady, + makeE2eOutputDir, + parseStdoutJson, + runCommandE2e, +} from "./helpers.ts"; +import { AUTH_ROUTES } from "./topic-routes.ts"; /** * Auth 相关 E2E:只验证 CLI 进程能正常解析参数并退出。 */ describe("e2e: auth", () => { - test("auth 分组展示子命令帮助且退出码为 0", async () => { - const { stdout, stderr, exitCode } = await runCli(["auth"]); - expect(exitCode, stderr).toBe(0); - const out = `${stdout}\n${stderr}`; - expect(out).toMatch(/auth|Authentication|login|logout|status/i); - }); - test("auth login --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["auth", "login", "--help"]); + const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "login", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/login|api-key/i); expect(stderr).toMatch(/--console-site/); expect(stderr).toMatch(/--open-api/); }); - test("auth logout --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["auth", "logout", "--help"]); - expect(exitCode, stderr).toBe(0); - expect(stderr).toMatch(/logout|dry-run|yes/i); - }); - - test("auth status --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["auth", "status", "--help"]); - expect(exitCode, stderr).toBe(0); - expect(stderr).toMatch(/status|output/i); - }); - - test("auth login 缺少 --api-key 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["auth", "login", "--quiet"]); - expect(exitCode, stderr).toBe(2); - expect(stderr).toMatch(/Choose exactly one login mode/); - }); - test("auth login 一次只能选择一种登录模式", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "login", "--console", @@ -54,11 +35,16 @@ describe("e2e: auth", () => { }); test("auth login 模式专属参数不能脱离对应模式", async () => { - const openApiFlagOnly = await runCli(["auth", "login", "--access-key-id", "LTAI-e2e"]); + const openApiFlagOnly = await runCommandE2e(AUTH_ROUTES, [ + "auth", + "login", + "--access-key-id", + "LTAI-e2e", + ]); expect(openApiFlagOnly.exitCode).toBe(2); expect(openApiFlagOnly.stderr).toMatch(/Use --open-api with --access-key-id/); - const baseUrlWithoutApiKey = await runCli([ + const baseUrlWithoutApiKey = await runCommandE2e(AUTH_ROUTES, [ "auth", "login", "--console", @@ -68,7 +54,7 @@ describe("e2e: auth", () => { expect(baseUrlWithoutApiKey.exitCode).toBe(2); expect(baseUrlWithoutApiKey.stderr).toMatch(/Use --base-url only with --api-key/); - const consoleSiteWithoutConsole = await runCli([ + const consoleSiteWithoutConsole = await runCommandE2e(AUTH_ROUTES, [ "auth", "login", "--api-key", @@ -81,7 +67,7 @@ describe("e2e: auth", () => { }); test("auth login --open-api 要求 AK/SK 成对输入", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "login", "--open-api", @@ -92,8 +78,26 @@ describe("e2e: auth", () => { expect(stderr).toMatch(/Provide --access-key-id and --access-key-secret with --open-api/); }); + test("auth logout --help 正常退出", async () => { + const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/logout|dry-run|yes/i); + }); + + test("auth status --help 正常退出", async () => { + const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/status|output/i); + }); + + test("auth login 缺少 --api-key 时报用法错误并退出 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "login", "--quiet"]); + expect(exitCode, stderr).toBe(2); + expect(stderr).toMatch(/Choose exactly one login mode/); + }); + test("auth login --dry-run --api-key 不发起校验与落盘", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "login", "--dry-run", @@ -105,7 +109,7 @@ describe("e2e: auth", () => { }); test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "login", "--dry-run", @@ -121,7 +125,12 @@ describe("e2e: auth", () => { }); test("auth login 缺少密钥且 --output json 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["auth", "login", "--output", "json"]); + const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ + "auth", + "login", + "--output", + "json", + ]); expect(exitCode).toBe(2); const err = JSON.parse(stderr.trim()) as { error?: { code?: number; message?: string } }; expect(err.error?.code).toBe(2); @@ -129,20 +138,29 @@ describe("e2e: auth", () => { }); test("auth logout --dry-run 不写入配置", async () => { - const { stdout, stderr, exitCode } = await runCli(["auth", "logout", "--dry-run"]); + const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ + "auth", + "logout", + "--dry-run", + ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("No changes made."); expect(stderr).not.toContain("Cleared api_key"); }); test("auth logout --dry-run --quiet", async () => { - const { stdout, stderr, exitCode } = await runCli(["auth", "logout", "--dry-run", "--quiet"]); + const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ + "auth", + "logout", + "--dry-run", + "--quiet", + ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("No changes made."); }); test("auth logout --dry-run --output json(不清除密钥)", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "logout", "--dry-run", @@ -155,7 +173,12 @@ describe("e2e: auth", () => { }); test.skipIf(!isDashScopeE2EReady())("auth status 文本输出", async () => { - const { stdout, stderr, exitCode } = await runCli(["auth", "status", "--output", "text"]); + const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ + "auth", + "status", + "--output", + "text", + ]); expect(exitCode, stderr).toBe(0); expect(stdout).toMatch( /Authentication Status|API key:|Console token:|DashScope API:|Console gateway:/, @@ -163,7 +186,12 @@ describe("e2e: auth", () => { }); test.skipIf(!isDashScopeE2EReady())("auth status --output json", async () => { - const { stdout, stderr, exitCode } = await runCli(["auth", "status", "--output", "json"]); + const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ + "auth", + "status", + "--output", + "json", + ]); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson<{ authenticated?: boolean; @@ -176,7 +204,8 @@ describe("e2e: auth", () => { test.skipIf(!isDashScopeE2EReady())( "auth status --output json --quiet(base_url 经 env 指定;凭证域 flag 对 status 不可见)", async () => { - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCommandE2e( + AUTH_ROUTES, ["auth", "status", "--output", "json", "--quiet"], { DASHSCOPE_BASE_URL: "https://dashscope.aliyuncs.com" }, ); @@ -188,16 +217,25 @@ describe("e2e: auth", () => { ); test("auth status 不接受凭证域覆盖 flag(--base-url 报 Unknown flag)", async () => { - const { stderr, exitCode } = await runCli(["auth", "status", "--base-url", "https://x.test"]); + const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ + "auth", + "status", + "--base-url", + "https://x.test", + ]); expect(exitCode).not.toBe(0); expect(stderr).toMatch(/Unknown flag.*--base-url/); }); test("auth status 展示 env OpenAPI AK/SK 且不接受 OpenAPI flag 覆盖", async () => { - const { stdout, stderr, exitCode } = await runCli(["auth", "status", "--output", "json"], { - ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-e2e-placeholder", - ALIBABA_CLOUD_ACCESS_KEY_SECRET: "secret-e2e-placeholder", - }); + const { stdout, stderr, exitCode } = await runCommandE2e( + AUTH_ROUTES, + ["auth", "status", "--output", "json"], + { + ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-e2e-placeholder", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "secret-e2e-placeholder", + }, + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson<{ authenticated?: boolean; @@ -208,7 +246,7 @@ describe("e2e: auth", () => { expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-placeholder"); expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-placeholder"); - const denied = await runCli(["auth", "status", "--access-key-id", "ak"]); + 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/); }); @@ -221,7 +259,8 @@ describe("e2e: auth", () => { ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", }; - const login = await runCli( + const login = await runCommandE2e( + AUTH_ROUTES, [ "auth", "login", @@ -245,7 +284,7 @@ describe("e2e: auth", () => { expect(config.openapi_access_key_id).toBeUndefined(); expect(config.openapi_access_key_secret).toBeUndefined(); - const status = await runCli(["auth", "status", "--output", "json"], env); + const status = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env); expect(status.exitCode, status.stderr).toBe(0); const data = parseStdoutJson<{ authenticated?: boolean; @@ -256,11 +295,11 @@ describe("e2e: auth", () => { expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-login-placeholder"); expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-login-placeholder"); - const logout = await runCli(["auth", "logout", "--open-api"], env); + const logout = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--open-api"], env); expect(logout.exitCode, logout.stderr).toBe(0); expect(logout.stderr).toMatch(/Cleared access_key_id/); - const after = await runCli(["auth", "status", "--output", "json"], env); + const after = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env); expect(after.exitCode, after.stderr).toBe(0); const afterData = parseStdoutJson<{ authenticated?: boolean; openapi?: unknown }>(after.stdout); expect(afterData.openapi).toBeUndefined(); diff --git a/packages/cli/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts similarity index 74% rename from packages/cli/tests/e2e/config.e2e.test.ts rename to packages/commands/tests/e2e/config.e2e.test.ts index 0ea439c..66c7122 100644 --- a/packages/cli/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -1,32 +1,31 @@ import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCli } from "./helpers.ts"; +import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { CONFIG_ROUTES } from "./topic-routes.ts"; /** * Config 相关 E2E */ describe("e2e: config", () => { - test("config 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["config"]); - expect(exitCode, stderr).toBe(0); - const out = `${stdout}\n${stderr}`; - expect(out).toMatch(/config|show|set/i); - }); - test("config show --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["config", "show", "--help"]); + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "show", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/show|config/i); }); test("config set --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["config", "set", "--help"]); + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/set|--key|--value/i); }); test("config show --output json", async () => { - const { stdout, stderr, exitCode } = await runCli(["config", "show", "--output", "json"]); + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + "config", + "show", + "--output", + "json", + ]); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson<{ config_file?: string; @@ -39,19 +38,24 @@ describe("e2e: config", () => { }); test("config show --output text", async () => { - const { stdout, stderr, exitCode } = await runCli(["config", "show", "--output", "text"]); + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + "config", + "show", + "--output", + "text", + ]); expect(exitCode, stderr).toBe(0); expect(stdout).toMatch(/config_file|timeout|base_url/i); }); test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["config", "set", "--quiet"]); + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--quiet"]); expect(exitCode, stderr).toBe(2); expect(stderr).toMatch(/--key|--value|Usage:/i); }); test("config set 非法 key 时退出为用法错误", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--key", @@ -64,7 +68,7 @@ describe("e2e: config", () => { }); test("config set 非法 output", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--key", @@ -77,7 +81,7 @@ describe("e2e: config", () => { }); test("config set 非法 timeout", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--key", @@ -90,7 +94,7 @@ describe("e2e: config", () => { }); test("config set --dry-run 不落盘(仅输出 would_set)", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--dry-run", @@ -107,7 +111,7 @@ describe("e2e: config", () => { }); test("config set --dry-run 支持连字符别名 key", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--dry-run", @@ -124,7 +128,7 @@ describe("e2e: config", () => { }); test("config set --dry-run 支持 AccessKey 短字段别名", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--dry-run", @@ -141,7 +145,7 @@ describe("e2e: config", () => { }); test("config set 不接受旧 OpenAPI AccessKey 字段名", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", "set", "--key", diff --git a/packages/cli/tests/e2e/console-flags.e2e.test.ts b/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts similarity index 58% rename from packages/cli/tests/e2e/console-flags.e2e.test.ts rename to packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts index f5b8a6e..4fafea5 100644 --- a/packages/cli/tests/e2e/console-flags.e2e.test.ts +++ b/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCli } from "./helpers.ts"; +import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { CONSOLE_FLAGS_DRY_RUN_ROUTES } from "./topic-routes.ts"; type ConsoleDryRunMeta = { consoleRegion?: string; @@ -7,55 +8,13 @@ type ConsoleDryRunMeta = { consoleSwitchAgent?: number; }; -/** - * E2E for global console flags (`--console-region`, `--console-site`, - * `--console-switch-agent`) and DashScope `--base-url`. - */ - -describe("e2e: console global flags", () => { - test("根帮助展示 --base-url 与 console 全局标志", async () => { - const { stderr, exitCode } = await runCli(["--help"]); - expect(exitCode, stderr).toBe(0); - expect(stderr).toMatch(/--base-url/); - expect(stderr).toMatch(/--console-region/); - expect(stderr).toMatch(/--console-site/); - expect(stderr).toMatch(/--console-switch-agent/); - expect(stderr).not.toMatch(/^\s*--region\s/m); - }); - - test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => { - const { stderr, exitCode } = await runCli(["quota", "check", "--help"]); - expect(exitCode, stderr).toBe(0); - expect(stderr).toMatch(/Global Flags:/); - expect(stderr).toMatch(/--console-region /); - expect(stderr).toMatch(/--model /); - expect(stderr).toMatch(/--period /); - expect(stderr).toMatch(/--output /); - expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/); - }); - - test("跨域 flag 拒绝:model 命令传 --console-region 报 Unknown flag", async () => { - const { stderr, exitCode } = await runCli([ - "text", - "chat", - "--message", - "hi", - "--console-region", - "cn-hangzhou", - "--dry-run", - ]); - expect(exitCode).not.toBe(0); - expect(stderr).toMatch(/Unknown flag.*--console-region/); - }); - - test("跨域 flag 拒绝:console 命令传 --api-key 报 Unknown flag", async () => { - const { stderr, exitCode } = await runCli(["mcp", "list", "--api-key", "sk-test", "--dry-run"]); - expect(exitCode).not.toBe(0); - expect(stderr).toMatch(/Unknown flag.*--api-key/); - }); - +describe("e2e: console global flags (dry-run)", () => { test("console call --help 不暴露命令级 region/site,示例使用 --console-region", async () => { - const { stderr, exitCode } = await runCli(["console", "call", "--help"]); + const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ + "console", + "call", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--api /); expect(stderr).toMatch(/--data /); @@ -65,7 +24,11 @@ describe("e2e: console global flags", () => { }); test("auth login --help:自有 flag 含 --console-site,不含其余 console 域 flag", async () => { - const { stderr, exitCode } = await runCli(["auth", "login", "--help"]); + const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ + "auth", + "login", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--api-key /); expect(stderr).toMatch(/--base-url /); @@ -75,7 +38,7 @@ describe("e2e: console global flags", () => { }); test("console call --dry-run 默认 consoleRegion 为 cn-beijing", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "console", "call", "--api", @@ -93,7 +56,7 @@ describe("e2e: console global flags", () => { }); test("console call --dry-run --console-region / --console-site / --console-switch-agent 透传", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "console", "call", "--api", @@ -118,7 +81,7 @@ describe("e2e: console global flags", () => { }); test("console call 拒绝未知全局 flag --region", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "console", "call", "--api", @@ -134,7 +97,7 @@ describe("e2e: console global flags", () => { }); test("mcp list --dry-run --console-region 透传", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "mcp", "list", "--dry-run", @@ -149,7 +112,7 @@ describe("e2e: console global flags", () => { }); test("quota check --dry-run --console-region 透传", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "quota", "check", "--dry-run", diff --git a/packages/cli/tests/e2e/dataset.e2e.test.ts b/packages/commands/tests/e2e/dataset.e2e.test.ts similarity index 77% rename from packages/cli/tests/e2e/dataset.e2e.test.ts rename to packages/commands/tests/e2e/dataset.e2e.test.ts index 2118ff8..9ef4d26 100644 --- a/packages/cli/tests/e2e/dataset.e2e.test.ts +++ b/packages/commands/tests/e2e/dataset.e2e.test.ts @@ -1,9 +1,7 @@ import { describe, expect, test } from "vite-plus/test"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); +import { join } from "path"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e, e2eFixturesDir } from "./helpers.ts"; +import { DATASET_ROUTES } from "./topic-routes.ts"; /** * Dataset (fine-tune file) E2E. @@ -19,22 +17,19 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); */ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { - test("dataset --help 列出子命令", async () => { - const { stdout, stderr, exitCode } = await runCli(["dataset"]); - expect(exitCode, stderr).toBe(0); - const out = `${stdout}\n${stderr}`; - expect(out).toMatch(/upload|list|get|delete|validate/); - }); - test("dataset upload --help 正常退出并展示 --file", async () => { - const { stderr, exitCode } = await runCli(["dataset", "upload", "--help"]); + const { stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ + "dataset", + "upload", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--file|jsonl/i); }); test("dataset validate 通过合法 JSONL", async () => { - const file = join(__dirname, ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -49,8 +44,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { }); test("dataset validate 拒绝 pretty-printed JSON 并以非零码退出", async () => { - const file = join(__dirname, ".dataset-invalid.jsonl"); - const { stdout, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-invalid.jsonl"); + const { stdout, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -68,8 +63,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { }); test("dataset upload --no-validate --dry-run 跳过本地校验", async () => { - const file = join(__dirname, ".dataset-invalid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-invalid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "upload", "--file", @@ -88,8 +83,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { test("dataset validate 自动识别 DPO 并校验 chosen/rejected", async () => { // No --schema: a record carrying chosen/rejected is auto-detected as DPO // and the valid fixture passes. - const file = join(__dirname, ".dataset-dpo-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-dpo-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -106,8 +101,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { test("dataset validate 自动识别 CPT 并校验 {text} 记录", async () => { // No --schema: a record carrying `text` (and no `messages`) is auto-detected // as CPT and the valid fixture passes. - const file = join(__dirname, ".dataset-cpt-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-cpt-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -122,8 +117,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { }); test("dataset validate --schema cpt 拒绝缺失 text 的记录", async () => { - const file = join(__dirname, ".dataset-valid.jsonl"); // SFT {messages}, no text - const { stdout, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-valid.jsonl"); // SFT {messages}, no text + const { stdout, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -142,8 +137,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { }); test("dataset validate --schema dpo 拒绝缺失 rejected 的记录", async () => { - const file = join(__dirname, ".dataset-dpo-invalid.jsonl"); - const { stdout, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-dpo-invalid.jsonl"); + const { stdout, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -163,8 +158,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { test("dataset validate --schema chatml 忽略 chosen/rejected(不报 DPO 错误)", async () => { // Same invalid-DPO file, but --schema chatml must not run DPO checks. - const file = join(__dirname, ".dataset-dpo-invalid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-dpo-invalid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -181,8 +176,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { }); test("dataset validate --schema 以非零码退出", async () => { - const file = join(__dirname, ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -197,8 +192,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { }); test("dataset upload --dry-run 转发 --schema", async () => { - const file = join(__dirname, ".dataset-dpo-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-dpo-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "upload", "--file", @@ -218,8 +213,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { test("dataset upload --schema image --no-validate --dry-run 采用 1GB 媒体上限", async () => { // image schema raises the upload cap to 1 GiB (vs 300 MB for text). // --no-validate keeps this offline (the jsonl fixture is not a real zip). - const file = join(__dirname, ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "upload", "--file", @@ -239,8 +234,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { }); test.each(["tts", "image"])("dataset upload --dry-run 接受媒体 schema %s", async (schema) => { - const file = join(__dirname, ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "upload", "--file", @@ -259,8 +254,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { }); test("dataset validate --schema video 拒绝(视频生成入口已隐藏)", async () => { - const file = join(__dirname, ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "validate", "--file", @@ -275,8 +270,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { }); test("dataset upload --schema video 拒绝(视频生成入口已隐藏)", async () => { - const file = join(__dirname, ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const file = join(e2eFixturesDir, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "upload", "--file", @@ -295,7 +290,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => { describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (DashScope)", () => { test("dataset list --output json 返回结构化结果", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(DATASET_ROUTES, [ "dataset", "list", "--page-size", diff --git a/packages/cli/tests/e2e/deploy.e2e.test.ts b/packages/commands/tests/e2e/deploy.e2e.test.ts similarity index 87% rename from packages/cli/tests/e2e/deploy.e2e.test.ts rename to packages/commands/tests/e2e/deploy.e2e.test.ts index 3dc5c55..6fb5ded 100644 --- a/packages/cli/tests/e2e/deploy.e2e.test.ts +++ b/packages/commands/tests/e2e/deploy.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { DEPLOY_ROUTES } from "./topic-routes.ts"; /** * Deploy E2E. @@ -15,20 +16,25 @@ import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { test("deploy 列出子命令", async () => { - const { stdout, stderr, exitCode } = await runCli(["deploy"]); + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, ["deploy"]); expect(exitCode, stderr).toBe(0); const out = `${stdout}\n${stderr}`; expect(out).toMatch(/create|list|get|delete|update|scale|models/); }); test("deploy create --help 正常退出并展示必填项", async () => { - const { stderr, exitCode } = await runCli(["deploy", "text", "create", "--help"]); + const { stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ + "deploy", + "text", + "create", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--model|--name/i); }); test("deploy create --dry-run 构造 lora 部署请求体", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", "text", "create", @@ -58,7 +64,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { }); test("deploy create --plan mu --deploy-spec --dry-run 透传 deploy_spec", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", "text", "create", @@ -92,7 +98,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { // the default plan to `mu` (text/image stay `lora`). In dry-run the mu // strategy skips the catalog lookup, so deploy_spec is omitted and capacity // falls back to 1 with billing_method POST_PAY. - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", "audio", "create", @@ -117,7 +123,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { }); test("deploy scale --dry-run 转发 capacity", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", "scale", "--deployed-model", @@ -140,7 +146,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { }); test("deploy update --dry-run 转发 rate limits", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", "update", "--deployed-model", @@ -164,7 +170,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { }); test("deploy scale --dry-run 缺少 capacity/input-tpm/output-tpm 时报错", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", "scale", "--deployed-model", @@ -184,7 +190,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => { ["models", ["--source", "custom"]], ["delete", ["--deployed-model", "dep-xxx"]], ])("deploy %s --dry-run 发出结构化动作", async (sub, extra) => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", sub, ...extra, @@ -207,7 +213,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (DashScope)", () => { * 而非进程崩溃),即视为通过。 */ test("deploy list --output json 优雅返回(空账号或鉴权失败均通过)", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(DEPLOY_ROUTES, [ "deploy", "list", "--page-size", diff --git a/packages/cli/tests/e2e/file-upload.e2e.test.ts b/packages/commands/tests/e2e/file-upload.e2e.test.ts similarity index 57% rename from packages/cli/tests/e2e/file-upload.e2e.test.ts rename to packages/commands/tests/e2e/file-upload.e2e.test.ts index 241910f..5b7912c 100644 --- a/packages/cli/tests/e2e/file-upload.e2e.test.ts +++ b/packages/commands/tests/e2e/file-upload.e2e.test.ts @@ -1,24 +1,19 @@ import { describe, expect, test } from "vite-plus/test"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); +import { join } from "path"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e, e2eFixturesDir } from "./helpers.ts"; +import { FILE_UPLOAD_ROUTES } from "./topic-routes.ts"; /** * File upload E2E */ describe("e2e: file upload", () => { - test("file 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["file"]); - expect(exitCode, stderr).toBe(0); - const out = `${stdout}\n${stderr}`; - expect(out).toMatch(/file|upload/i); - }); - test("file upload --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["file", "upload", "--help"]); + const { stderr, exitCode } = await runCommandE2e(FILE_UPLOAD_ROUTES, [ + "file", + "upload", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/upload|--file|--model/i); }); @@ -26,21 +21,31 @@ describe("e2e: file upload", () => { describe.skipIf(!isDashScopeE2EReady())("e2e: file upload(DashScope)", () => { test("file upload 缺少 --file 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["file", "upload", "--model", "qwen3-vl-plus"]); + const { stderr, exitCode } = await runCommandE2e(FILE_UPLOAD_ROUTES, [ + "file", + "upload", + "--model", + "qwen3-vl-plus", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--file|Usage:/i); }); test("file upload 缺少 --model 时报用法错误并退出 (2)", async () => { - const testFile = join(__dirname, ".smoke-32.png"); - const { stderr, exitCode } = await runCli(["file", "upload", "--file", testFile]); + const testFile = join(e2eFixturesDir, ".smoke-32.png"); + const { stderr, exitCode } = await runCommandE2e(FILE_UPLOAD_ROUTES, [ + "file", + "upload", + "--file", + testFile, + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--model|Usage:/i); }); test("上传文件成功返回oss临时 URL", async () => { - const testFile = join(__dirname, ".smoke-32.png"); - const { stdout, stderr, exitCode } = await runCli([ + const testFile = join(e2eFixturesDir, ".smoke-32.png"); + const { stdout, stderr, exitCode } = await runCommandE2e(FILE_UPLOAD_ROUTES, [ "file", "upload", "--file", diff --git a/packages/cli/tests/e2e/finetune.e2e.test.ts b/packages/commands/tests/e2e/finetune.e2e.test.ts similarity index 86% rename from packages/cli/tests/e2e/finetune.e2e.test.ts rename to packages/commands/tests/e2e/finetune.e2e.test.ts index 8af40f5..59e9374 100644 --- a/packages/cli/tests/e2e/finetune.e2e.test.ts +++ b/packages/commands/tests/e2e/finetune.e2e.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vite-plus/test"; import { join } from "path"; -import { isDashScopeE2EReady, parseStdoutJson, runCli, cliPackageRoot } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e, e2eFixturesDir } from "./helpers.ts"; +import { FINETUNE_ROUTES } from "./topic-routes.ts"; /** * Fine-tune E2E. @@ -16,20 +17,25 @@ import { isDashScopeE2EReady, parseStdoutJson, runCli, cliPackageRoot } from "./ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { test("finetune 列出子命令", async () => { - const { stdout, stderr, exitCode } = await runCli(["finetune"]); + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, ["finetune"]); expect(exitCode, stderr).toBe(0); const out = `${stdout}\n${stderr}`; expect(out).toMatch(/create|list|get|cancel|delete|logs|checkpoints|export|watch|capability/); }); test("finetune create --help 正常退出并展示必填项", async () => { - const { stderr, exitCode } = await runCli(["finetune", "text", "create", "--help"]); + const { stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ + "finetune", + "text", + "create", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--model|--datasets/i); }); test("finetune create --dry-run 构造 SFT 默认请求体", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "text", "create", @@ -63,7 +69,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { }); test("finetune create --dry-run 转发训练类型与超参", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "text", "create", @@ -125,7 +131,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { ])( "finetune create --training-type %s 经 profile 映射为 server 类型 %s", async (cliType, serverType) => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "text", "create", @@ -147,7 +153,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { ); test("finetune create --training-type 拒绝不支持的训练类型值", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "text", "create", @@ -165,8 +171,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { }); test("finetune create --dry-run 把本地路径标记为 pending 上传且不发起网络请求", async () => { - const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const localPath = join(e2eFixturesDir, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "text", "create", @@ -197,7 +203,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { }); test("finetune create --datasets 为空时拒绝", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "text", "create", @@ -217,8 +223,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { // so 3 <= 8 trips the pre-submit gate. The gate fires before any upload, // so this is fully offline (no key, no network) — the proof is that the // error is the gate message AND no "Uploaded …" line ever appears. - const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const localPath = join(e2eFixturesDir, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "text", "create", @@ -239,8 +245,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { test("finetune create --batch-size 过小仍按 8 下限比较(不绕过卡口)", async () => { // Even with --batch-size 1 (server clamps to 8), 3 samples <= 8 still trips // the gate — confirms the gate uses the clamped/effective batch, not the raw. - const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl"); - const { stdout, stderr, exitCode } = await runCli([ + const localPath = join(e2eFixturesDir, ".dataset-valid.jsonl"); + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "text", "create", @@ -268,7 +274,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { ["watch", ["--job-id", "ft-xxx"]], ["capability", ["--model", "qwen3-8b"]], ])("finetune %s --dry-run 发出结构化动作", async (sub, extra) => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", sub, ...extra, @@ -282,7 +288,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { }); test("finetune create --dry-run 解析多 datasets 中的空白", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "text", "create", @@ -304,7 +310,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { test("finetune audio create --dry-run 用 sft-lora 默认 + audio 超参", async () => { // Audio has no --training-type flag; the command fixes modality=audio and // defaults to sft-lora (efficient_sft) with the fixed CosyVoice hyper-params. - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "audio", "create", @@ -330,14 +336,19 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => { test("finetune audio create --help 不暴露文本超参 flag", async () => { // Audio models don't consume --training-type / --n-epochs / --batch-size / // --learning-rate / --max-length, so those flags are not offered. - const { stderr, exitCode } = await runCli(["finetune", "audio", "create", "--help"]); + const { stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ + "finetune", + "audio", + "create", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--model|--datasets/i); expect(stderr).not.toMatch(/--training-type|--n-epochs|--batch-size|--max-length/); }); test("finetune image create --dry-run 用 sft-lora 默认", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "image", "create", @@ -365,7 +376,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (DashScope)", () => { * 而非进程崩溃),即视为通过。 */ test("finetune list --output json 优雅返回(空账号或鉴权失败均通过)", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [ "finetune", "list", "--page-size", diff --git a/packages/cli/tests/e2e/.dataset-cpt-valid.jsonl b/packages/commands/tests/e2e/fixtures/.dataset-cpt-valid.jsonl similarity index 100% rename from packages/cli/tests/e2e/.dataset-cpt-valid.jsonl rename to packages/commands/tests/e2e/fixtures/.dataset-cpt-valid.jsonl diff --git a/packages/cli/tests/e2e/.dataset-dpo-invalid.jsonl b/packages/commands/tests/e2e/fixtures/.dataset-dpo-invalid.jsonl similarity index 100% rename from packages/cli/tests/e2e/.dataset-dpo-invalid.jsonl rename to packages/commands/tests/e2e/fixtures/.dataset-dpo-invalid.jsonl diff --git a/packages/cli/tests/e2e/.dataset-dpo-valid.jsonl b/packages/commands/tests/e2e/fixtures/.dataset-dpo-valid.jsonl similarity index 100% rename from packages/cli/tests/e2e/.dataset-dpo-valid.jsonl rename to packages/commands/tests/e2e/fixtures/.dataset-dpo-valid.jsonl diff --git a/packages/cli/tests/e2e/.dataset-invalid.jsonl b/packages/commands/tests/e2e/fixtures/.dataset-invalid.jsonl similarity index 100% rename from packages/cli/tests/e2e/.dataset-invalid.jsonl rename to packages/commands/tests/e2e/fixtures/.dataset-invalid.jsonl diff --git a/packages/cli/tests/e2e/.dataset-valid.jsonl b/packages/commands/tests/e2e/fixtures/.dataset-valid.jsonl similarity index 100% rename from packages/cli/tests/e2e/.dataset-valid.jsonl rename to packages/commands/tests/e2e/fixtures/.dataset-valid.jsonl diff --git a/packages/cli/tests/e2e/.smoke-32.png b/packages/commands/tests/e2e/fixtures/.smoke-32.png similarity index 100% rename from packages/cli/tests/e2e/.smoke-32.png rename to packages/commands/tests/e2e/fixtures/.smoke-32.png diff --git a/packages/commands/tests/e2e/harness/identity.ts b/packages/commands/tests/e2e/harness/identity.ts new file mode 100644 index 0000000..ff6d5f1 --- /dev/null +++ b/packages/commands/tests/e2e/harness/identity.ts @@ -0,0 +1,13 @@ +import pkg from "../../../package.json" with { type: "json" }; + +/** + * commands E2E harness 产品身份。 + * version 取自 commands 包(与 monorepo 同步);npmPackage 使用非发布名以阻断 + * versionCheckStage 的 registry 查询与 CI 中的 auto-update。 + */ +export const E2E_HARNESS_IDENTITY = { + binName: "bl", + version: pkg.version, + clientName: "commands-e2e", + npmPackage: "bailian-cli-commands-e2e-harness", +} as const; diff --git a/packages/commands/tests/e2e/harness/main.ts b/packages/commands/tests/e2e/harness/main.ts new file mode 100644 index 0000000..a6c65d6 --- /dev/null +++ b/packages/commands/tests/e2e/harness/main.ts @@ -0,0 +1,29 @@ +import * as cmd from "bailian-cli-commands"; +import type { AnyCommand } from "bailian-cli-core"; +import { createCli } from "bailian-cli-runtime"; +import { E2E_HARNESS_IDENTITY } from "./identity.ts"; + +interface RouteSpec { + path: string; + export: string; +} + +function buildRoutesFromEnv(): Record { + const raw = process.env.BAILIAN_E2E_ROUTES; + if (!raw?.trim()) { + throw new Error("BAILIAN_E2E_ROUTES is required for commands E2E harness"); + } + const spec = JSON.parse(raw) as RouteSpec[]; + const routes: Record = {}; + const lib = cmd as Record; + for (const { path, export: exportName } of spec) { + const command = lib[exportName]; + if (typeof command !== "object" || command === null || !("run" in command)) { + throw new Error(`Unknown bailian-cli-commands export: ${exportName}`); + } + routes[path] = command as AnyCommand; + } + return routes; +} + +void createCli(buildRoutesFromEnv(), E2E_HARNESS_IDENTITY).run(); diff --git a/packages/commands/tests/e2e/helpers.ts b/packages/commands/tests/e2e/helpers.ts new file mode 100644 index 0000000..0ee7375 --- /dev/null +++ b/packages/commands/tests/e2e/helpers.ts @@ -0,0 +1,65 @@ +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { + cliTimeoutPrefix, + cliTimeoutSeconds, + e2eLabelFromMetaUrl, + isConsoleAuthFailure, + makeE2eOutputDir, + parseStdoutJson, +} from "e2e/output"; +import { runNodeMain, type RunCliResult } from "e2e/runner"; +import type { E2eRouteExports } from "./topic-routes.ts"; + +export { + cliTimeoutPrefix, + cliTimeoutSeconds, + e2eLabelFromMetaUrl, + isConsoleAuthFailure, + makeE2eOutputDir, + parseStdoutJson, +}; +export type { RunCliResult }; + +export { + isBailianE2EEnabled, + isBailianE2EMediaEnabled, + isBailianE2EVideoEnabled, + isChatE2EReady, + isConsoleE2EReady, + isDashScopeE2EReady, + isSearchE2EReady, +} from "e2e/gating"; + +const e2eDir = dirname(fileURLToPath(import.meta.url)); +const harnessMainTs = join(e2eDir, "harness", "main.ts"); + +/** `packages/commands` 根目录 */ +export const commandsPackageRoot = join(e2eDir, "..", ".."); + +/** E2E fixtures 目录 */ +export const e2eFixturesDir = join(e2eDir, "fixtures"); + +function serializeRoutes(routes: E2eRouteExports): string { + return JSON.stringify( + Object.entries(routes).map(([path, exportName]) => ({ path, export: exportName })), + ); +} + +/** + * 通过 harness 子进程执行命令 E2E。 + * `routes` 为本用例所需的最小 path → export 映射,不维护全量产品 map。 + */ +export async function runCommandE2e( + routes: E2eRouteExports, + args: string[], + envOverrides: NodeJS.ProcessEnv = {}, +): Promise { + return runNodeMain(harnessMainTs, args, { + cwd: commandsPackageRoot, + env: { + BAILIAN_E2E_ROUTES: serializeRoutes(routes), + ...envOverrides, + }, + }); +} diff --git a/packages/cli/tests/e2e/image-edit.e2e.test.ts b/packages/commands/tests/e2e/image-edit.e2e.test.ts similarity index 75% rename from packages/cli/tests/e2e/image-edit.e2e.test.ts rename to packages/commands/tests/e2e/image-edit.e2e.test.ts index 6a11685..486bbca 100644 --- a/packages/cli/tests/e2e/image-edit.e2e.test.ts +++ b/packages/commands/tests/e2e/image-edit.e2e.test.ts @@ -1,37 +1,29 @@ import { describe, expect, test } from "vite-plus/test"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; +import { join } from "path"; import { + e2eFixturesDir, e2eLabelFromMetaUrl, isBailianE2EMediaEnabled, isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); +import { IMAGE_ROUTES } from "./topic-routes.ts"; /** * Image edit E2E */ describe("e2e: image edit", () => { - test("image 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["image"]); - expect(exitCode, stderr).toBe(0); - const out = `${stdout}\n${stderr}`; - expect(out).toMatch(/image|generate|edit/i); - }); - test("image edit --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["image", "edit", "--help"]); + const { stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, ["image", "edit", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/edit|--image|--prompt|--async|--concurrent/i); }); test("image edit --dry-run 接受 async 模型的 --async 与 --concurrent", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ "image", "edit", "--dry-run", @@ -58,21 +50,31 @@ describe("e2e: image edit", () => { describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: image edit", () => { test("image edit 缺少 --image 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["image", "edit", "--prompt", "仅提示词"]); + const { stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ + "image", + "edit", + "--prompt", + "仅提示词", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--image|Usage:/i); }); test("image edit 缺少 --prompt 时报用法错误并退出 (2)", async () => { - const testPng = join(__dirname, ".smoke-32.png"); - const { stderr, exitCode } = await runCli(["image", "edit", "--image", testPng]); + const testPng = join(e2eFixturesDir, ".smoke-32.png"); + const { stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ + "image", + "edit", + "--image", + testPng, + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--prompt|Usage:/i); }); test("【qwen-image-2.0】图片编辑", async () => { const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); - const gen = await runCli([ + const gen = await runCommandE2e(IMAGE_ROUTES, [ "image", "generate", "--model", @@ -92,7 +94,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: ima expect(imagePath).toBeTruthy(); - const ed = await runCli([ + const ed = await runCommandE2e(IMAGE_ROUTES, [ "image", "edit", "--model", diff --git a/packages/cli/tests/e2e/image-generate.e2e.test.ts b/packages/commands/tests/e2e/image-generate.e2e.test.ts similarity index 75% rename from packages/cli/tests/e2e/image-generate.e2e.test.ts rename to packages/commands/tests/e2e/image-generate.e2e.test.ts index 445fb5a..b987536 100644 --- a/packages/cli/tests/e2e/image-generate.e2e.test.ts +++ b/packages/commands/tests/e2e/image-generate.e2e.test.ts @@ -5,8 +5,9 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; +import { IMAGE_ROUTES } from "./topic-routes.ts"; /** * Image generate:先做 help / 分组等常规检测(不依赖密钥、不调生成接口)。 @@ -15,15 +16,8 @@ import { */ describe("e2e: image generate", () => { - test("image 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["image"]); - expect(exitCode, stderr).toBe(0); - const out = `${stdout}\n${stderr}`; - expect(out).toMatch(/image|generate|edit/i); - }); - test("image generate --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["image", "generate", "--help"]); + const { stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, ["image", "generate", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/generate|--prompt|--model/i); }); @@ -33,14 +27,19 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( "e2e: image generate", () => { test("image generate 缺少 --prompt 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["image", "generate", "--model", "qwen-image-2.0"]); + const { stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ + "image", + "generate", + "--model", + "qwen-image-2.0", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--prompt|Usage:/i); }); test("【qwen-image-2.0】图片生成", async () => { const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [ "image", "generate", "--model", diff --git a/packages/cli/tests/e2e/knowledge-chat.e2e.test.ts b/packages/commands/tests/e2e/knowledge-chat.e2e.test.ts similarity index 52% rename from packages/cli/tests/e2e/knowledge-chat.e2e.test.ts rename to packages/commands/tests/e2e/knowledge-chat.e2e.test.ts index 0640857..5ee66f5 100644 --- a/packages/cli/tests/e2e/knowledge-chat.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge-chat.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCli } from "./helpers.ts"; +import { isChatE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { KNOWLEDGE_CHAT_ROUTES } from "./topic-routes.ts"; interface ContentPart { type: string; @@ -24,7 +25,11 @@ interface DryRunBody { describe("e2e: knowledge chat", () => { test("knowledge chat --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["knowledge", "chat", "--help"]); + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ + "knowledge", + "chat", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--message/i); expect(stderr).toMatch(/--agent-id/i); @@ -32,19 +37,30 @@ describe("e2e: knowledge chat", () => { }); test("缺少 --message 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["knowledge", "chat", "--agent-id", "aid_test"]); + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ + "knowledge", + "chat", + "--agent-id", + "aid_test", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--message|Usage:/i); }); test("缺少 --agent-id 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["knowledge", "chat", "--message", "Hello"]); + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ + "knowledge", + "chat", + "--message", + "Hello", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--agent-id|Usage:/i); }); test("缺少 --workspace-id 时非零退出并提示", async () => { - const { stderr, exitCode } = await runCli( + const { stderr, exitCode } = await runCommandE2e( + KNOWLEDGE_CHAT_ROUTES, // 假 key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入 [ "knowledge", @@ -65,7 +81,7 @@ describe("e2e: knowledge chat", () => { }); test("--dry-run 输出 endpoint 和 request body", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--dry-run", @@ -88,7 +104,7 @@ describe("e2e: knowledge chat", () => { }); test("--dry-run 多轮消息解析 role:content 前缀", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--dry-run", @@ -118,7 +134,7 @@ describe("e2e: knowledge chat", () => { }); test("--dry-run + --image 输出多模态 content 数组", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--dry-run", @@ -147,7 +163,7 @@ describe("e2e: knowledge chat", () => { }); test("--dry-run + --image 无 --message 自动创建空 user message", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ "knowledge", "chat", "--dry-run", @@ -178,3 +194,133 @@ describe("e2e: knowledge chat", () => { }); }); }); + +interface ChatJsonResult { + answer: string; + request_id: string; +} + +describe.skipIf(!isChatE2EReady())("e2e: knowledge chat (live)", () => { + const agentId = process.env.BAILIAN_E2E_CHAT_AGENT_ID!; + const workspaceId = process.env.BAILIAN_WORKSPACE_ID!; + + test("chat (JSON mode) returns answer", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ + "knowledge", + "chat", + "--message", + "什么是大模型?", + "--agent-id", + agentId, + "--workspace-id", + workspaceId, + "--output", + "json", + ]); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.answer).toBeTruthy(); + expect(data.answer.length).toBeGreaterThan(0); + expect(data.request_id).toBeTruthy(); + }); + + test("chat (text mode) returns plain text", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ + "knowledge", + "chat", + "--message", + "什么是RAG?", + "--agent-id", + agentId, + "--workspace-id", + workspaceId, + "--output", + "text", + ]); + + expect(exitCode, stderr).toBe(0); + expect(stdout.trim().length).toBeGreaterThan(0); + }); + + test("chat (stream, JSON mode) collects and returns answer", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ + "knowledge", + "chat", + "--message", + "什么是检索增强生成?", + "--agent-id", + agentId, + "--workspace-id", + workspaceId, + "--output", + "json", + ]); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.answer).toBeTruthy(); + expect(data.answer.length).toBeGreaterThan(0); + expect(data.request_id).toBeTruthy(); + }); + + test("chat (stream, text mode) outputs streaming text", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ + "knowledge", + "chat", + "--message", + "什么是向量检索?", + "--agent-id", + agentId, + "--workspace-id", + workspaceId, + "--output", + "text", + ]); + + expect(exitCode, stderr).toBe(0); + expect(stdout.trim().length).toBeGreaterThan(0); + }); + + test("chat with multi-turn messages returns context-aware answer", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ + "knowledge", + "chat", + "--message", + "user:什么是大模型", + "--message", + "assistant:大模型是大规模语言模型,具有强大的理解和生成能力", + "--message", + "它有哪些应用场景?", + "--agent-id", + agentId, + "--workspace-id", + workspaceId, + "--output", + "json", + ]); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.answer).toBeTruthy(); + expect(data.answer.length).toBeGreaterThan(0); + }); + + test("chat with invalid agent_id fails gracefully", async () => { + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [ + "knowledge", + "chat", + "--message", + "test", + "--agent-id", + "aid-invalid-not-exist", + "--workspace-id", + workspaceId, + "--output", + "json", + ]); + + expect(exitCode).not.toBe(0); + expect(stderr).toBeTruthy(); + }); +}); diff --git a/packages/commands/tests/e2e/knowledge-search.e2e.test.ts b/packages/commands/tests/e2e/knowledge-search.e2e.test.ts new file mode 100644 index 0000000..fdbdf47 --- /dev/null +++ b/packages/commands/tests/e2e/knowledge-search.e2e.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, test } from "vite-plus/test"; +import { isSearchE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { KNOWLEDGE_SEARCH_ROUTES } from "./topic-routes.ts"; + +interface DryRunBody { + endpoint?: string; + request?: { + query?: string; + agent_id?: string; + images?: string[]; + query_history?: Array<{ role: string; content: string }>; + }; +} + +describe("e2e: knowledge search", () => { + test("knowledge search --help 正常退出", async () => { + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ + "knowledge", + "search", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--query/i); + expect(stderr).toMatch(/--agent-id/i); + expect(stderr).toMatch(/--workspace-id/i); + expect(stderr).toMatch(/--image/i); + expect(stderr).toMatch(/--query-history/i); + }); + + test("缺少 --query 时报用法错误并退出 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ + "knowledge", + "search", + "--agent-id", + "aid_test", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--query|Usage:/i); + }); + + test("缺少 --agent-id 时报用法错误并退出 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ + "knowledge", + "search", + "--query", + "test", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--agent-id|Usage:/i); + }); + + test("缺少 --workspace-id 时非零退出并提示", async () => { + const { stderr, exitCode } = await runCommandE2e( + KNOWLEDGE_SEARCH_ROUTES, + // 假 key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入 + [ + "knowledge", + "search", + "--query", + "test", + "--agent-id", + "aid_test", + "--api-key", + "sk-fake", + "--output", + "json", + ], + { BAILIAN_WORKSPACE_ID: "", BAILIAN_CONFIG_DIR: "/tmp" }, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/workspace.*required/i); + }); + + test("--dry-run 输出 endpoint 和 request body", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ + "knowledge", + "search", + "--dry-run", + "--query", + "什么是RAG", + "--agent-id", + "aid_test", + "--workspace-id", + "ws_test", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.endpoint).toMatch(/ws_test\.cn-beijing\.maas\.aliyuncs\.com/); + expect(data.endpoint).toMatch(/api\/v1\/indices\/knowledge\/search/); + expect(data.request?.query).toBe("什么是RAG"); + expect(data.request?.agent_id).toBe("aid_test"); + }); + + test("--dry-run + --image 输出 images", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ + "knowledge", + "search", + "--dry-run", + "--query", + "test", + "--agent-id", + "aid_test", + "--workspace-id", + "ws_test", + "--image", + "https://example.com/a.jpg", + "--image", + "https://example.com/b.jpg", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.request?.images).toEqual([ + "https://example.com/a.jpg", + "https://example.com/b.jpg", + ]); + }); + + test("--dry-run + --query-history 输出用户对话历史", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ + "knowledge", + "search", + "--dry-run", + "--query", + "它怎么工作", + "--agent-id", + "aid_test", + "--workspace-id", + "ws_test", + "--query-history", + '[{"role":"user","content":"什么是RAG"},{"role":"assistant","content":"RAG是检索增强生成"}]', + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.request?.query_history).toEqual([ + { role: "user", content: "什么是RAG" }, + { role: "assistant", content: "RAG是检索增强生成" }, + ]); + }); + + test("--dry-run + --query-history 无效 JSON 非零退出", async () => { + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ + "knowledge", + "search", + "--dry-run", + "--query", + "test", + "--agent-id", + "aid_test", + "--workspace-id", + "ws_test", + "--query-history", + "not-valid-json", + "--output", + "json", + ]); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/query-history.*valid JSON/i); + }); +}); + +interface SearchResponse { + code: string; + status_code: number; + request_id: string; + data: { + total: number; + cost_time: number; + nodes: Array<{ + score: number; + text: string; + metadata: Record; + }>; + }; +} + +describe.skipIf(!isSearchE2EReady())("e2e: knowledge search (live)", () => { + const agentId = process.env.BAILIAN_E2E_SEARCH_AGENT_ID!; + const workspaceId = process.env.BAILIAN_WORKSPACE_ID!; + + test("search returns results in JSON mode", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ + "knowledge", + "search", + "--query", + "什么是大模型", + "--agent-id", + agentId, + "--workspace-id", + workspaceId, + "--output", + "json", + ]); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.code).toBe("Success"); + expect(data.request_id).toBeTruthy(); + expect(data.data.total).toBeGreaterThan(0); + expect(data.data.nodes.length).toBeGreaterThan(0); + + const firstNode = data.data.nodes[0]!; + expect(typeof firstNode.score).toBe("number"); + expect(firstNode.score).toBeGreaterThanOrEqual(0); + expect(typeof firstNode.text).toBe("string"); + expect(firstNode.text.length).toBeGreaterThan(0); + }); + + test("search returns results in text mode", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ + "knowledge", + "search", + "--query", + "RAG", + "--agent-id", + agentId, + "--workspace-id", + workspaceId, + "--output", + "text", + ]); + + expect(exitCode, stderr).toBe(0); + expect(stdout).toMatch(/\[1\].*score/); + }); + + test("search with --query-history returns results", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ + "knowledge", + "search", + "--query", + "它怎么工作", + "--agent-id", + agentId, + "--workspace-id", + workspaceId, + "--query-history", + '[{"role":"user","content":"什么是大模型"},{"role":"assistant","content":"大模型是大规模语言模型"}]', + "--output", + "json", + ]); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.code).toBe("Success"); + expect(data.data.nodes.length).toBeGreaterThan(0); + }); + + test("search with invalid agent_id fails gracefully", async () => { + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [ + "knowledge", + "search", + "--query", + "test", + "--agent-id", + "aid-invalid-not-exist", + "--workspace-id", + workspaceId, + "--output", + "json", + ]); + + expect(exitCode).not.toBe(0); + expect(stderr).toBeTruthy(); + }); +}); diff --git a/packages/cli/tests/e2e/knowledge.e2e.test.ts b/packages/commands/tests/e2e/knowledge.e2e.test.ts similarity index 82% rename from packages/cli/tests/e2e/knowledge.e2e.test.ts rename to packages/commands/tests/e2e/knowledge.e2e.test.ts index f4c960c..cfb58d4 100644 --- a/packages/cli/tests/e2e/knowledge.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { KNOWLEDGE_ROUTES } from "./topic-routes.ts"; // ---- Types ---- @@ -20,15 +21,12 @@ interface DryRunBody { // ---- Help & missing args (no credentials needed) ---- describe("e2e: knowledge retrieve", () => { - test("knowledge 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["knowledge"]); - expect(exitCode, stderr).toBe(0); - const out = `${stdout}\n${stderr}`; - expect(out).toMatch(/knowledge|retrieve/i); - }); - test("knowledge retrieve --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["knowledge", "retrieve", "--help"]); + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_ROUTES, [ + "knowledge", + "retrieve", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--index-id/i); expect(stderr).toMatch(/--query/i); @@ -37,13 +35,23 @@ describe("e2e: knowledge retrieve", () => { }); test("缺少 --index-id 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["knowledge", "retrieve", "--query", "test"]); + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_ROUTES, [ + "knowledge", + "retrieve", + "--query", + "test", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--index-id|Usage:/i); }); test("缺少 --query 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["knowledge", "retrieve", "--index-id", "idx_test"]); + const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_ROUTES, [ + "knowledge", + "retrieve", + "--index-id", + "idx_test", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--query|Usage:/i); }); @@ -53,7 +61,8 @@ describe("e2e: knowledge retrieve", () => { describe.skipIf(!isDashScopeE2EReady())("e2e: knowledge retrieve errors", () => { test("无任何凭证时提示缺少密钥并非零退出", async () => { - const { stderr, exitCode } = await runCli( + const { stderr, exitCode } = await runCommandE2e( + KNOWLEDGE_ROUTES, ["knowledge", "retrieve", "--index-id", "idx_test", "--query", "test", "--output", "json"], { DASHSCOPE_API_KEY: "", @@ -70,7 +79,8 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: knowledge retrieve errors", () => describe("e2e: knowledge retrieve dry-run", () => { test("--dry-run 输出 endpoint 和 snake_case body", async () => { - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCommandE2e( + KNOWLEDGE_ROUTES, [ "knowledge", "retrieve", @@ -92,7 +102,8 @@ describe("e2e: knowledge retrieve dry-run", () => { }); test("--dry-run + --top-k 转发到 rerank_top_n 并输出废弃警告", async () => { - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCommandE2e( + KNOWLEDGE_ROUTES, [ "knowledge", "retrieve", @@ -115,7 +126,8 @@ describe("e2e: knowledge retrieve dry-run", () => { }); test("--dry-run + --rerank-top-n 优先于 --top-k", async () => { - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCommandE2e( + KNOWLEDGE_ROUTES, [ "knowledge", "retrieve", @@ -139,7 +151,8 @@ describe("e2e: knowledge retrieve dry-run", () => { }); test("--dry-run + rerank 参数完整输出", async () => { - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCommandE2e( + KNOWLEDGE_ROUTES, [ "knowledge", "retrieve", diff --git a/packages/cli/tests/e2e/mcp.e2e.test.ts b/packages/commands/tests/e2e/mcp.e2e.test.ts similarity index 84% rename from packages/cli/tests/e2e/mcp.e2e.test.ts rename to packages/commands/tests/e2e/mcp.e2e.test.ts index fec9919..ad5d06e 100644 --- a/packages/cli/tests/e2e/mcp.e2e.test.ts +++ b/packages/commands/tests/e2e/mcp.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { MCP_ROUTES } from "./topic-routes.ts"; /** * `bl mcp` E2E. @@ -16,40 +17,32 @@ import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; */ describe("e2e: mcp", () => { - test("mcp 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["mcp"]); - expect(exitCode, stderr).toBe(0); - const out = `${stdout}\n${stderr}`; - expect(out).toMatch(/mcp/i); - expect(out).toMatch(/list|tools|call/i); - }); - test("mcp list --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["mcp", "list", "--help"]); + const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, ["mcp", "list", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/list|--name|--type|--page/i); }); test("mcp tools --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["mcp", "tools", "--help"]); + const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, ["mcp", "tools", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/tools|--server|--url/i); }); test("mcp call --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["mcp", "call", "--help"]); + const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, ["mcp", "call", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/call|--target|--arg|--json/i); }); test("mcp list --help 不暴露 --all 入口(市场全量已下线)", async () => { - const { stderr, exitCode } = await runCli(["mcp", "list", "--help"]); + const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, ["mcp", "list", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).not.toMatch(/--all/); }); test("mcp list --dry-run 仅打印计划且固定 activated=1", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ "mcp", "list", "--dry-run", @@ -87,7 +80,7 @@ describe("e2e: mcp", () => { }); test("mcp list --dry-run 自定义 --console-region 透传", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ "mcp", "list", "--dry-run", @@ -102,7 +95,7 @@ describe("e2e: mcp", () => { }); test("mcp tools --server --dry-run 输出 /api/v1/mcps//mcp 形态 URL", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ "mcp", "tools", "--server", @@ -121,7 +114,7 @@ describe("e2e: mcp", () => { }); test("mcp tools --url 覆盖 baseUrl 约定", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ "mcp", "tools", "--server", @@ -139,13 +132,13 @@ describe("e2e: mcp", () => { }); test("mcp tools 缺少 --server 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["mcp", "tools", "--quiet"]); + const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, ["mcp", "tools", "--quiet"]); expect(exitCode, stderr).toBe(2); expect(stderr).toMatch(/--server|Usage:/i); }); test("mcp call --target --dry-run 输出工具调用计划", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ "mcp", "call", "--target", @@ -171,7 +164,7 @@ describe("e2e: mcp", () => { }); test("mcp call --json 与 --arg 合并(arg 覆盖 json),--query 等价 arg.query", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ "mcp", "call", "--target", @@ -207,7 +200,7 @@ describe("e2e: mcp", () => { }); test("mcp call --target 缺少 . 时报错且非零退出", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ "mcp", "call", "--target", @@ -220,7 +213,7 @@ describe("e2e: mcp", () => { }); test("mcp call --arg 非 K=V 形式时报错且非零退出", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ "mcp", "call", "--target", @@ -235,7 +228,7 @@ describe("e2e: mcp", () => { }); test("mcp call --json 无效 JSON 报错且非零退出", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ "mcp", "call", "--target", @@ -250,7 +243,7 @@ describe("e2e: mcp", () => { }); test("mcp call 缺少 --target 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["mcp", "call", "--quiet"]); + const { stderr, exitCode } = await runCommandE2e(MCP_ROUTES, ["mcp", "call", "--quiet"]); expect(exitCode, stderr).toBe(2); expect(stderr).toMatch(/--target|Usage:/i); }); @@ -261,7 +254,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: mcp (live)", () => { // Regression: bailianMcpUrl previously added an `AliyunBailianMCP_` prefix, // which made every real call 500. This test asserts the convention-built URL // (no --url override) actually reaches a live MCP server end-to-end. - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ "mcp", "tools", "--server", diff --git a/packages/cli/tests/e2e/memory.e2e.test.ts b/packages/commands/tests/e2e/memory.e2e.test.ts similarity index 83% rename from packages/cli/tests/e2e/memory.e2e.test.ts rename to packages/commands/tests/e2e/memory.e2e.test.ts index 66bcf9d..f81c6d7 100644 --- a/packages/cli/tests/e2e/memory.e2e.test.ts +++ b/packages/commands/tests/e2e/memory.e2e.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "vite-plus/test"; -import { isBailianE2EEnabled, isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { + isBailianE2EEnabled, + isDashScopeE2EReady, + parseStdoutJson, + runCommandE2e, +} from "./helpers.ts"; +import { MEMORY_ROUTES } from "./topic-routes.ts"; interface MemoryAddBody { memory_ids?: string[]; @@ -31,33 +37,31 @@ function memoryLibraryCliArgs(): string[] { */ describe("e2e: memory", () => { - test("memory 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["memory"]); - expect(exitCode, stderr).toBe(0); - const out = `${stdout}\n${stderr}`; - expect(out).toMatch(/memory|add|list|search|update|delete|profile/i); - }); - test("memory add --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["memory", "add", "--help"]); + const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, ["memory", "add", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/add|--user-id|--content|messages/i); }); test("memory list --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["memory", "list", "--help"]); + const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, ["memory", "list", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/list|--user-id|memory-library/i); }); test("memory search --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["memory", "search", "--help"]); + const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, ["memory", "search", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/search|--query|user-id/i); }); test("memory profile create --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["memory", "profile", "create", "--help"]); + const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [ + "memory", + "profile", + "create", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/profile|create|user-id/i); }); @@ -70,7 +74,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( "e2e: memory CRUD + search", () => { test("memory add 缺少 --user-id 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [ "memory", "add", ...memoryLibraryCliArgs(), @@ -83,7 +87,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( test("memory add 缺少 --messages 与 --content 时报错正常退出", async () => { const userId = process.env.BAILIAN_E2E_MEMORY_USER_ID?.trim() || DEFAULT_E2E_MEMORY_USER_ID; - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [ "memory", "add", ...memoryLibraryCliArgs(), @@ -96,7 +100,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( test("memory add --dry-run 仅输出计划且不入网", async () => { const userId = process.env.BAILIAN_E2E_MEMORY_USER_ID?.trim() || DEFAULT_E2E_MEMORY_USER_ID; - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [ "memory", "add", "--dry-run", @@ -121,7 +125,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( const contentA = "CLI vp test:记忆写入(可删)"; const contentB = "CLI vp test:记忆已更新"; - const addRes = await runCli([ + const addRes = await runCommandE2e(MEMORY_ROUTES, [ "memory", "add", ...memoryLibraryCliArgs(), @@ -136,7 +140,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( const added = parseStdoutJson(addRes.stdout); expect(added.request_id?.length ?? 0, addRes.stdout + addRes.stderr).toBeGreaterThan(0); - const listRes = await runCli([ + const listRes = await runCommandE2e(MEMORY_ROUTES, [ "memory", "list", ...memoryLibraryCliArgs(), @@ -157,7 +161,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( const nodeId = listed.memory_nodes![0]!.memory_node_id.trim(); expect(nodeId.length).toBeGreaterThan(0); - const searchRes = await runCli([ + const searchRes = await runCommandE2e(MEMORY_ROUTES, [ "memory", "search", ...memoryLibraryCliArgs(), @@ -174,7 +178,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( const searched = parseStdoutJson(searchRes.stdout); expect(searched.memory_nodes?.length ?? 0).toBeGreaterThan(0); - const updRes = await runCli([ + const updRes = await runCommandE2e(MEMORY_ROUTES, [ "memory", "update", ...memoryLibraryCliArgs(), @@ -189,7 +193,7 @@ describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())( ]); expect(updRes.exitCode, updRes.stderr).toBe(0); - const delRes = await runCli([ + const delRes = await runCommandE2e(MEMORY_ROUTES, [ "memory", "delete", ...memoryLibraryCliArgs(), diff --git a/packages/cli/tests/e2e/omni.e2e.test.ts b/packages/commands/tests/e2e/omni.e2e.test.ts similarity index 84% rename from packages/cli/tests/e2e/omni.e2e.test.ts rename to packages/commands/tests/e2e/omni.e2e.test.ts index b6f6853..93804d0 100644 --- a/packages/cli/tests/e2e/omni.e2e.test.ts +++ b/packages/commands/tests/e2e/omni.e2e.test.ts @@ -6,12 +6,13 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; +import { OMNI_ROUTES } from "./topic-routes.ts"; describe("e2e: omni", () => { test("omni --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["omni", "--help"]); + const { stderr, exitCode } = await runCommandE2e(OMNI_ROUTES, ["omni", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/omni|--message|--audio|text-only/i); }); @@ -21,7 +22,10 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( "e2e: omni(DashScope 媒体)", () => { test("omni --list-voices 输出音色列表并退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["omni", "--list-voices"]); + const { stdout, stderr, exitCode } = await runCommandE2e(OMNI_ROUTES, [ + "omni", + "--list-voices", + ]); expect(exitCode, stderr).toBe(0); expect(stdout).toMatch(/Omni output voices:/); expect(stdout).toMatch(/Tina/); @@ -30,13 +34,17 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( }); test("omni 缺少 --message 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["omni", "--model", "qwen3.5-omni-flash"]); + const { stderr, exitCode } = await runCommandE2e(OMNI_ROUTES, [ + "omni", + "--model", + "qwen3.5-omni-flash", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--message|Usage:/i); }); test("omni --audio 无法识别扩展名时退出为用法错误 (2)", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(OMNI_ROUTES, [ "omni", "--model", "qwen3.5-omni-flash", @@ -51,7 +59,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( }); test("omni --dry-run --audio 构造 input_audio 而非 audio_url", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(OMNI_ROUTES, [ "omni", "--dry-run", "--model", @@ -91,7 +99,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( const clipText = "端到端Omni音频测试"; const clipWav = join(outDir, "e2e-omni-input.wav"); - const syn = await runCli([ + const syn = await runCommandE2e(OMNI_ROUTES, [ "speech", "synthesize", "--model", @@ -109,7 +117,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( ]); expect(syn.exitCode, syn.stderr).toBe(0); - const omni = await runCli([ + const omni = await runCommandE2e(OMNI_ROUTES, [ "omni", "--model", "qwen3.5-omni-flash", diff --git a/packages/cli/tests/e2e/pipeline.e2e.test.ts b/packages/commands/tests/e2e/pipeline.e2e.test.ts similarity index 83% rename from packages/cli/tests/e2e/pipeline.e2e.test.ts rename to packages/commands/tests/e2e/pipeline.e2e.test.ts index 5fa8b85..f6f01ca 100644 --- a/packages/cli/tests/e2e/pipeline.e2e.test.ts +++ b/packages/commands/tests/e2e/pipeline.e2e.test.ts @@ -2,7 +2,8 @@ import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { parseStdoutJson, runCli } from "./helpers.ts"; +import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { PIPELINE_ROUTES } from "./topic-routes.ts"; describe("e2e: pipeline", () => { let tempDir: string; @@ -63,30 +64,29 @@ describe("e2e: pipeline", () => { await rm(tempDir, { recursive: true, force: true }); }); - test("pipeline 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["pipeline"]); - expect(exitCode, stderr).toBe(0); - const out = `${stdout}\n${stderr}`; - expect(out).toMatch(/pipeline|run|validate/i); - expect(out).toMatch(/Minimal workflow\.yaml|text\/chat|bl pipeline run workflow\.yaml/i); - expect(out).toMatch(/Say hello in one short sentence|--dry-run --output json/i); - }); - test("pipeline run --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["pipeline", "run", "--help"]); + const { stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [ + "pipeline", + "run", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/pipeline run|--input|--input-file|--events|--concurrency/i); expect(stderr).not.toMatch(/--session-(?:dir|id)/i); }); test("pipeline validate --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["pipeline", "validate", "--help"]); + const { stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [ + "pipeline", + "validate", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/pipeline validate|workflow\.json|output json/i); }); test("pipeline validate --output json 校验合法 workflow", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [ "pipeline", "validate", "--file", @@ -101,7 +101,8 @@ describe("e2e: pipeline", () => { }); test("pipeline validate 使用 config 输出格式", async () => { - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCommandE2e( + PIPELINE_ROUTES, ["pipeline", "validate", "--file", chatBasicPath], { DASHSCOPE_OUTPUT: "text", @@ -112,7 +113,7 @@ describe("e2e: pipeline", () => { }); test("pipeline validate 拒绝非法依赖 workflow", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [ "pipeline", "validate", "--file", @@ -128,13 +129,17 @@ describe("e2e: pipeline", () => { }); test("pipeline run 缺少 --file 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["pipeline", "run", "--quiet"]); + const { stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [ + "pipeline", + "run", + "--quiet", + ]); expect(exitCode, stderr).toBe(2); expect(stderr).toMatch(/Usage: bl pipeline run --file |--file/i); }); test("pipeline run --dry-run --output json 仅输出计划", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [ "pipeline", "run", "--file", @@ -167,7 +172,8 @@ describe("e2e: pipeline", () => { }); test("pipeline run 使用 config 输出格式", async () => { - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCommandE2e( + PIPELINE_ROUTES, ["pipeline", "run", "--file", chatBasicPath, "--input", '{"message":"hello"}', "--dry-run"], { DASHSCOPE_OUTPUT: "text" }, ); @@ -177,7 +183,7 @@ describe("e2e: pipeline", () => { }); test("pipeline run --verbose 打印总步数和当前步骤序号", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [ "pipeline", "run", "--file", @@ -193,7 +199,7 @@ describe("e2e: pipeline", () => { }); test("pipeline run --events jsonl 在 dry-run 下输出生命周期事件", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [ "pipeline", "run", "--file", @@ -221,7 +227,7 @@ describe("e2e: pipeline", () => { }); test("pipeline run 拒绝未知 events format", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(PIPELINE_ROUTES, [ "pipeline", "run", "--file", diff --git a/packages/cli/tests/e2e/quota.e2e.test.ts b/packages/commands/tests/e2e/quota.e2e.test.ts similarity index 74% rename from packages/cli/tests/e2e/quota.e2e.test.ts rename to packages/commands/tests/e2e/quota.e2e.test.ts index 857d5d8..b0223d3 100644 --- a/packages/cli/tests/e2e/quota.e2e.test.ts +++ b/packages/commands/tests/e2e/quota.e2e.test.ts @@ -1,16 +1,22 @@ import { describe, expect, test } from "vite-plus/test"; -import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts"; +import { + isConsoleE2EReady, + isConsoleAuthFailure, + parseStdoutJson, + runCommandE2e, +} from "./helpers.ts"; +import { QUOTA_ROUTES } from "./topic-routes.ts"; describe("e2e: quota", () => { test("quota list --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["quota", "list", "--help"]); + const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toContain("--model"); expect(stderr).toContain("--all"); }); test("quota list --help 包含所有示例", async () => { - const { stderr, exitCode } = await runCli(["quota", "list", "--help"]); + const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toContain("bl quota list"); expect(stderr).toContain("bl quota list --model qwen3.6-plus"); @@ -18,21 +24,21 @@ describe("e2e: quota", () => { }); test("quota request --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["quota", "request", "--help"]); + const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "request", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toContain("--model"); expect(stderr).toContain("--tpm"); }); test("quota history --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["quota", "history", "--help"]); + const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "history", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toContain("--page"); expect(stderr).toContain("--model"); }); test("quota check --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["quota", "check", "--help"]); + const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "check", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toContain("--model"); expect(stderr).toContain("--period"); @@ -40,7 +46,12 @@ describe("e2e: quota", () => { }); test("quota check --period 0 报错最小值", async () => { - const { stderr, exitCode } = await runCli(["quota", "check", "--period", "0.5"]); + const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ + "quota", + "check", + "--period", + "0.5", + ]); expect(exitCode).toBe(2); expect(stderr).toContain("at least 1 minute"); }); @@ -48,7 +59,7 @@ describe("e2e: quota", () => { describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { test("quota list --dry-run 输出请求参数", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "list", "--dry-run", @@ -68,7 +79,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota list --dry-run --all 不传 supports 过滤", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "list", "--all", @@ -84,19 +95,26 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota list 文本输出包含英文表头", async () => { - const result = await runCli(["quota", "list", "--output", "text"]); + const result = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--output", "text"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("quota list --model 指定模型返回结果", async () => { - const result = await runCli(["quota", "list", "--model", "qwen3.6-plus", "--output", "text"]); + const result = await runCommandE2e(QUOTA_ROUTES, [ + "quota", + "list", + "--model", + "qwen3.6-plus", + "--output", + "text", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("quota list --model 不存在的模型报错", async () => { - const result = await runCli([ + const result = await runCommandE2e(QUOTA_ROUTES, [ "quota", "list", "--model", @@ -110,13 +128,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota list JSON 输出包含 model/rpm/tpm/maxTPM", async () => { - const result = await runCli(["quota", "list", "--output", "json"]); + const result = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--output", "json"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("quota request --dry-run 输出请求参数", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "request", "--model", @@ -138,7 +156,14 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota request TPM 超范围报错", async () => { - const result = await runCli(["quota", "request", "--model", "qwen3.6-plus", "--tpm", "999"]); + const result = await runCommandE2e(QUOTA_ROUTES, [ + "quota", + "request", + "--model", + "qwen3.6-plus", + "--tpm", + "999", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode).toBe(2); expect(result.stderr).toContain("out of range"); @@ -147,7 +172,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota request 不支持提额的模型报错", async () => { - const result = await runCli([ + const result = await runCommandE2e(QUOTA_ROUTES, [ "quota", "request", "--model", @@ -161,7 +186,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota history --dry-run 输出请求参数", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "history", "--dry-run", @@ -179,7 +204,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota check --dry-run 输出 API 信息", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "check", "--dry-run", @@ -196,7 +221,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota check --dry-run --console-region 透传", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "check", "--dry-run", @@ -211,19 +236,26 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota check 文本输出包含英文表头", async () => { - const result = await runCli(["quota", "check", "--output", "text"]); + const result = await runCommandE2e(QUOTA_ROUTES, ["quota", "check", "--output", "text"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("quota check --model 指定单模型", async () => { - const result = await runCli(["quota", "check", "--model", "qwen3.6-plus", "--output", "text"]); + const result = await runCommandE2e(QUOTA_ROUTES, [ + "quota", + "check", + "--model", + "qwen3.6-plus", + "--output", + "text", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("quota check --model 逗号分隔多模型", async () => { - const result = await runCli([ + const result = await runCommandE2e(QUOTA_ROUTES, [ "quota", "check", "--model", @@ -236,13 +268,20 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota check JSON 输出包含用量和限额字段", async () => { - const result = await runCli(["quota", "check", "--model", "qwen3.6-plus", "--output", "json"]); + const result = await runCommandE2e(QUOTA_ROUTES, [ + "quota", + "check", + "--model", + "qwen3.6-plus", + "--output", + "json", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("quota history --dry-run --page 2 --page-size 20", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "history", "--page", diff --git a/packages/cli/tests/e2e/search-web.e2e.test.ts b/packages/commands/tests/e2e/search-web.e2e.test.ts similarity index 78% rename from packages/cli/tests/e2e/search-web.e2e.test.ts rename to packages/commands/tests/e2e/search-web.e2e.test.ts index 817f7f7..89e1b30 100644 --- a/packages/cli/tests/e2e/search-web.e2e.test.ts +++ b/packages/commands/tests/e2e/search-web.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { SEARCH_WEB_ROUTES } from "./topic-routes.ts"; function pagesFromSearchWebStdout(stdout: string): Array<{ title?: string; url?: string }> { const envelope = parseStdoutJson<{ content?: Array<{ type?: string; text?: string }> }>(stdout); @@ -15,21 +16,19 @@ function pagesFromSearchWebStdout(stdout: string): Array<{ title?: string; url?: */ describe("e2e: search web", () => { - test("search 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["search"]); - expect(exitCode, stderr).toBe(0); - const out = `${stdout}\n${stderr}`; - expect(out).toMatch(/search|web/i); - }); - test("search web --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["search", "web", "--help"]); + const { stderr, exitCode } = await runCommandE2e(SEARCH_WEB_ROUTES, [ + "search", + "web", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/web|--query|list-tools|count/i); }); test("search web --dry-run --list-tools 无需 --query 也无需凭证即可干跑", async () => { - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCommandE2e( + SEARCH_WEB_ROUTES, ["search", "web", "--dry-run", "--list-tools", "--output", "json"], { DASHSCOPE_API_KEY: undefined, @@ -44,13 +43,17 @@ describe("e2e: search web", () => { describe.skipIf(!isDashScopeE2EReady())("e2e: search web", () => { test("search web 缺少 --query 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["search", "web", "--quiet"]); + const { stderr, exitCode } = await runCommandE2e(SEARCH_WEB_ROUTES, [ + "search", + "web", + "--quiet", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--query|Usage:/i); }); test("search web --dry-run 仅输出计划且不调 MCP", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(SEARCH_WEB_ROUTES, [ "search", "web", "--dry-run", @@ -74,7 +77,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: search web", () => { }); test("联网搜索返回 JSON 且含搜索结果", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(SEARCH_WEB_ROUTES, [ "search", "web", "--query", diff --git a/packages/cli/tests/e2e/speech-list-voices.e2e.test.ts b/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts similarity index 61% rename from packages/cli/tests/e2e/speech-list-voices.e2e.test.ts rename to packages/commands/tests/e2e/speech-list-voices.e2e.test.ts index 65a7120..df9dc55 100644 --- a/packages/cli/tests/e2e/speech-list-voices.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts @@ -1,26 +1,28 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, runCommandE2e } from "./helpers.ts"; +import { SPEECH_ROUTES } from "./topic-routes.ts"; /** * Speech list-voices E2E */ describe("e2e: speech list-voices", () => { - test("speech 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["speech"]); - expect(exitCode, stderr).toBe(0); - const out = `${stdout}\n${stderr}`; - expect(out).toMatch(/speech|synthesize|recognize/i); - }); - test("speech synthesize --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["speech", "synthesize", "--help"]); + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "synthesize", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/synthesize|--text|--voice|list-voices|model/i); }); test("speech recognize --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["speech", "recognize", "--help"]); + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "recognize", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/recognize|--url|audio|model/i); }); @@ -28,13 +30,17 @@ describe("e2e: speech list-voices", () => { describe.skipIf(!isDashScopeE2EReady())("e2e: speech list-voices", () => { test("speech synthesize 缺少 --text 且非 --list-voices 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["speech", "synthesize", "--quiet"]); + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "synthesize", + "--quiet", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--text|Usage:/i); }); test("【cosyvoice-v3-flash】获取音色列表", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ "speech", "synthesize", "--list-voices", diff --git a/packages/cli/tests/e2e/speech-recognize.e2e.test.ts b/packages/commands/tests/e2e/speech-recognize.e2e.test.ts similarity index 79% rename from packages/cli/tests/e2e/speech-recognize.e2e.test.ts rename to packages/commands/tests/e2e/speech-recognize.e2e.test.ts index cc4a106..926af0e 100644 --- a/packages/cli/tests/e2e/speech-recognize.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-recognize.e2e.test.ts @@ -7,22 +7,21 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; +import { SPEECH_ROUTES } from "./topic-routes.ts"; /** * Speech recognize:help / 分组不依赖密钥;识别流程需媒体 E2E + DashScope。 */ describe("e2e: speech recognize", () => { - test("speech 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["speech"]); - expect(exitCode, stderr).toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/speech|synthesize|recognize/i); - }); - test("speech recognize --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["speech", "recognize", "--help"]); + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "recognize", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/recognize|--url|model|audio/i); }); @@ -32,7 +31,11 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( "e2e: speech recognize(DashScope 媒体)", () => { test("speech recognize 缺少 --url 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["speech", "recognize", "--quiet"]); + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "recognize", + "--quiet", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--url|Usage:/i); }); @@ -40,7 +43,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( test("【fun-asr】语音识别", async () => { const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); const outMp3 = join(outDir, "e2e-tts.mp3"); - const syn = await runCli([ + const syn = await runCommandE2e(SPEECH_ROUTES, [ "speech", "synthesize", "--model", @@ -60,7 +63,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( expect(audioUrl?.startsWith("http")).toBe(true); const asrJson = join(outDir, "e2e-asr.json"); - const rec = await runCli([ + const rec = await runCommandE2e(SPEECH_ROUTES, [ "speech", "recognize", "--model", diff --git a/packages/cli/tests/e2e/speech-synthesize.e2e.test.ts b/packages/commands/tests/e2e/speech-synthesize.e2e.test.ts similarity index 81% rename from packages/cli/tests/e2e/speech-synthesize.e2e.test.ts rename to packages/commands/tests/e2e/speech-synthesize.e2e.test.ts index 6dfe1a0..4881bca 100644 --- a/packages/cli/tests/e2e/speech-synthesize.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-synthesize.e2e.test.ts @@ -6,22 +6,21 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; +import { SPEECH_ROUTES } from "./topic-routes.ts"; /** * Speech synthesize:help / 分组不依赖密钥;合成本地需媒体 E2E + DashScope。 */ describe("e2e: speech synthesize", () => { - test("speech 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["speech"]); - expect(exitCode, stderr).toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/speech|synthesize|recognize/i); - }); - test("speech synthesize --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["speech", "synthesize", "--help"]); + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "synthesize", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/synthesize|--text|--voice|model/i); }); @@ -31,7 +30,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( "e2e: speech synthesize(DashScope 媒体)", () => { test("speech synthesize 缺少 --text 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ "speech", "synthesize", "--model", @@ -44,7 +43,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( }); test("speech synthesize --dry-run 仅输出 request 且不调 TTS", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ "speech", "synthesize", "--dry-run", @@ -68,7 +67,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( test("【cosyvoice-v3-flash】语音合成", async () => { const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); const outMp3 = join(outDir, "e2e-tts.mp3"); - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ "speech", "synthesize", "--model", diff --git a/packages/cli/tests/e2e/text-chat.e2e.test.ts b/packages/commands/tests/e2e/text-chat.e2e.test.ts similarity index 73% rename from packages/cli/tests/e2e/text-chat.e2e.test.ts rename to packages/commands/tests/e2e/text-chat.e2e.test.ts index c54b6fb..a5f4d5e 100644 --- a/packages/cli/tests/e2e/text-chat.e2e.test.ts +++ b/packages/commands/tests/e2e/text-chat.e2e.test.ts @@ -1,19 +1,14 @@ import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { TEXT_CHAT_ROUTES } from "./topic-routes.ts"; /** * Text chat:help / 分组不依赖密钥;对话需 DashScope。 */ describe("e2e: text chat", () => { - test("text 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["text"]); - expect(exitCode, stderr).toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/text|chat/i); - }); - test("text chat --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["text", "chat", "--help"]); + const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, ["text", "chat", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/chat|--message|model|stream/i); }); @@ -21,13 +16,18 @@ describe("e2e: text chat", () => { describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => { test("text chat 缺少 --message 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["text", "chat", "--model", "qwen3.7-max"]); + const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [ + "text", + "chat", + "--model", + "qwen3.7-max", + ]); expect(exitCode).toBe(2); expect(stderr).toMatch(/--message|Usage:/i); }); test("text chat --dry-run 仅输出 request 且不调对话接口", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [ "text", "chat", "--dry-run", @@ -49,7 +49,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => { }); test("【qwen3.7-max】文本对话", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [ "text", "chat", "--model", diff --git a/packages/cli/tests/e2e/token-plan.e2e.test.ts b/packages/commands/tests/e2e/token-plan.e2e.test.ts similarity index 62% rename from packages/cli/tests/e2e/token-plan.e2e.test.ts rename to packages/commands/tests/e2e/token-plan.e2e.test.ts index b37fb14..7bc6cda 100644 --- a/packages/cli/tests/e2e/token-plan.e2e.test.ts +++ b/packages/commands/tests/e2e/token-plan.e2e.test.ts @@ -1,16 +1,22 @@ import { describe, expect, test } from "vite-plus/test"; -import { makeE2eOutputDir, parseStdoutJson, runCli } from "./helpers.ts"; +import { makeE2eOutputDir, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { TOKEN_PLAN_ROUTES } from "./topic-routes.ts"; describe("e2e: token-plan", () => { test("token-plan help shows centralized OpenAPI auth flags", async () => { - const { stderr, exitCode } = await runCli(["token-plan", "list-seats", "--help"]); + const { stderr, exitCode } = await runCommandE2e(TOKEN_PLAN_ROUTES, [ + "token-plan", + "list-seats", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--access-key-id/); expect(stderr).toMatch(/--access-key-secret/); }); test("token-plan dry-run does not require OpenAPI AK/SK", async () => { - const { stdout, stderr, exitCode } = await runCli( + const { stdout, stderr, exitCode } = await runCommandE2e( + TOKEN_PLAN_ROUTES, ["token-plan", "list-seats", "--dry-run", "--output", "json"], { ALIBABA_CLOUD_ACCESS_KEY_ID: "", @@ -25,22 +31,30 @@ describe("e2e: token-plan", () => { test("token-plan non-dry-run requires OpenAPI AK/SK", async () => { const configDir = makeE2eOutputDir("token-plan-missing-openapi"); - const { stderr, exitCode } = await runCli(["token-plan", "list-seats"], { - BAILIAN_CONFIG_DIR: configDir, - ALIBABA_CLOUD_ACCESS_KEY_ID: "", - ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", - }); + const { stderr, exitCode } = await runCommandE2e( + TOKEN_PLAN_ROUTES, + ["token-plan", "list-seats"], + { + BAILIAN_CONFIG_DIR: configDir, + ALIBABA_CLOUD_ACCESS_KEY_ID: "", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", + }, + ); expect(exitCode).not.toBe(0); expect(stderr).toMatch(/OpenAPI AK\/SK|access-key-id|ALIBABA_CLOUD_ACCESS_KEY_ID/); }); test("token-plan partial OpenAPI env reports AK/SK hint without API key onboarding", async () => { const configDir = makeE2eOutputDir("token-plan-partial-openapi-env"); - const { stderr, exitCode } = await runCli(["token-plan", "list-seats"], { - BAILIAN_CONFIG_DIR: configDir, - ALIBABA_CLOUD_ACCESS_KEY_ID: "ak-e2e-placeholder", - ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", - }); + const { stderr, exitCode } = await runCommandE2e( + TOKEN_PLAN_ROUTES, + ["token-plan", "list-seats"], + { + BAILIAN_CONFIG_DIR: configDir, + ALIBABA_CLOUD_ACCESS_KEY_ID: "ak-e2e-placeholder", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", + }, + ); expect(exitCode).not.toBe(0); expect(stderr).toMatch(/Incomplete OpenAPI AK\/SK/); expect(stderr).toMatch(/ALIBABA_CLOUD_ACCESS_KEY_ID/); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts new file mode 100644 index 0000000..faf1850 --- /dev/null +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -0,0 +1,145 @@ +/** + * 各 topic E2E 的最小路由(path → bailian-cli-commands export 名)。 + * 仅包含该 topic 测试会调用的 path,不维护全量产品 map。 + */ +export type E2eRouteExports = Record; + +export const AUTH_ROUTES: E2eRouteExports = { + "auth login": "authLogin", + "auth status": "authStatus", + "auth logout": "authLogout", +}; + +export const TEXT_CHAT_ROUTES: E2eRouteExports = { "text chat": "textChat" }; + +export const CONFIG_ROUTES: E2eRouteExports = { + "config show": "configShow", + "config set": "configSet", +}; + +export const MEMORY_ROUTES: E2eRouteExports = { + "memory add": "memoryAdd", + "memory search": "memorySearch", + "memory list": "memoryList", + "memory update": "memoryUpdate", + "memory delete": "memoryDelete", + "memory profile create": "memoryProfileCreate", + "memory profile get": "memoryProfileGet", +}; + +export const KNOWLEDGE_ROUTES: E2eRouteExports = { + "knowledge retrieve": "knowledgeRetrieve", + "knowledge search": "knowledgeSearch", + "knowledge chat": "knowledgeChat", +}; + +export const KNOWLEDGE_SEARCH_ROUTES: E2eRouteExports = { + "knowledge search": "knowledgeSearch", +}; + +export const KNOWLEDGE_CHAT_ROUTES: E2eRouteExports = { + "knowledge chat": "knowledgeChat", +}; + +export const IMAGE_ROUTES: E2eRouteExports = { + "image generate": "imageGenerate", + "image edit": "imageEdit", +}; + +export const VIDEO_ROUTES: E2eRouteExports = { + "image generate": "imageGenerate", + "video generate": "videoGenerate", + "video edit": "videoEdit", + "video ref": "videoRef", + "video task get": "videoTaskGet", + "video download": "videoDownload", +}; + +export const SPEECH_ROUTES: E2eRouteExports = { + "speech synthesize": "speechSynthesize", + "speech recognize": "speechRecognize", +}; + +export const MCP_ROUTES: E2eRouteExports = { + "mcp call": "mcpCall", + "mcp list": "mcpList", + "mcp tools": "mcpTools", +}; + +export const SEARCH_WEB_ROUTES: E2eRouteExports = { "search web": "searchWeb" }; + +export const PIPELINE_ROUTES: E2eRouteExports = { + "pipeline run": "pipelineRun", + "pipeline validate": "pipelineValidate", +}; + +export const OMNI_ROUTES: E2eRouteExports = { + omni: "textOmni", + "speech synthesize": "speechSynthesize", +}; + +export const FILE_UPLOAD_ROUTES: E2eRouteExports = { "file upload": "fileUpload" }; + +export const ADVISOR_ROUTES: E2eRouteExports = { "advisor recommend": "advisorRecommend" }; + +export const QUOTA_ROUTES: E2eRouteExports = { + "quota list": "quotaList", + "quota request": "quotaRequest", + "quota history": "quotaHistory", + "quota check": "quotaCheck", +}; + +export const USAGE_ROUTES: E2eRouteExports = { + "usage free": "usageFree", + "usage freetier": "usageFreetier", + "usage stats": "usageStats", +}; + +export const DEPLOY_ROUTES: E2eRouteExports = { + "deploy text create": "deployTextCreate", + "deploy audio create": "deployAudioCreate", + "deploy image create": "deployImageCreate", + "deploy list": "deployList", + "deploy get": "deployGet", + "deploy models": "deployModels", + "deploy scale": "deployScale", + "deploy update": "deployUpdate", + "deploy delete": "deployDelete", +}; + +export const DATASET_ROUTES: E2eRouteExports = { + "dataset upload": "datasetUpload", + "dataset list": "datasetList", + "dataset get": "datasetGet", + "dataset delete": "datasetDelete", + "dataset validate": "datasetValidate", +}; + +export const FINETUNE_ROUTES: E2eRouteExports = { + "finetune text create": "finetuneTextCreate", + "finetune audio create": "finetuneAudioCreate", + "finetune image create": "finetuneImageCreate", + "finetune list": "finetuneList", + "finetune get": "finetuneGet", + "finetune cancel": "finetuneCancel", + "finetune delete": "finetuneDelete", + "finetune logs": "finetuneLogs", + "finetune checkpoints": "finetuneCheckpoints", + "finetune export": "finetuneExport", + "finetune watch": "finetuneWatch", + "finetune capability": "finetuneCapability", +}; + +export const CONSOLE_FLAGS_DRY_RUN_ROUTES: E2eRouteExports = { + "auth login": "authLogin", + "console call": "consoleCall", + "mcp list": "mcpList", + "quota check": "quotaCheck", +}; + +export const TOKEN_PLAN_ROUTES: E2eRouteExports = { + "token-plan list-seats": "tokenPlanListSeats", + "token-plan create-key": "tokenPlanCreateKey", + "token-plan assign-seats": "tokenPlanAssignSeats", + "token-plan add-member": "tokenPlanAddMember", +}; diff --git a/packages/cli/tests/e2e/usage-free.e2e.test.ts b/packages/commands/tests/e2e/usage-free.e2e.test.ts similarity index 70% rename from packages/cli/tests/e2e/usage-free.e2e.test.ts rename to packages/commands/tests/e2e/usage-free.e2e.test.ts index 40521d0..14eea08 100644 --- a/packages/cli/tests/e2e/usage-free.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-free.e2e.test.ts @@ -1,22 +1,21 @@ import { describe, expect, test } from "vite-plus/test"; -import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts"; +import { + isConsoleE2EReady, + isConsoleAuthFailure, + parseStdoutJson, + runCommandE2e, +} from "./helpers.ts"; +import { USAGE_ROUTES } from "./topic-routes.ts"; describe("e2e: usage free", () => { - test("usage 分组展示子命令帮助且退出码为 0", async () => { - const { stdout, stderr, exitCode } = await runCli(["usage"]); - expect(exitCode, stderr).toBe(0); - const out = `${stdout}\n${stderr}`; - expect(out).toMatch(/usage|free|freetier/i); - }); - test("usage free --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["usage", "free", "--help"]); + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, ["usage", "free", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--model|quota|free-tier/i); }); test("usage free --help 包含所有示例", async () => { - const { stderr, exitCode } = await runCli(["usage", "free", "--help"]); + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, ["usage", "free", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toContain("bl usage free"); expect(stderr).toContain("bl usage free --model qwen3-max"); @@ -26,7 +25,7 @@ describe("e2e: usage free", () => { describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { test("usage free --dry-run --model 输出请求参数不发起调用", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -45,7 +44,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --dry-run --model 逗号分隔多个模型", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -62,7 +61,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --dry-run --model 重复模型名自动去重", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -79,7 +78,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --dry-run --model 逗号间有空格也能正确解析", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -96,30 +95,57 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --dry-run 不指定 --model 传全量模型列表", async () => { - const { stderr, exitCode } = await runCli(["usage", "free", "--dry-run", "--output", "json"]); + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "free", + "--dry-run", + "--output", + "json", + ]); expect(exitCode, stderr).toBe(0); }); test("usage free --model 单模型查询返回 JSON 结果", async () => { - const result = await runCli(["usage", "free", "--model", "qwen3-max", "--output", "json"]); + const result = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "free", + "--model", + "qwen3-max", + "--output", + "json", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("usage free --model 单模型文本输出包含表头", async () => { - const result = await runCli(["usage", "free", "--model", "qwen3-max", "--output", "text"]); + const result = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "free", + "--model", + "qwen3-max", + "--output", + "text", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("usage free --model 文本输出包含模型名", async () => { - const result = await runCli(["usage", "free", "--model", "qwen3-max", "--output", "text"]); + const result = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "free", + "--model", + "qwen3-max", + "--output", + "text", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("usage free --model 逗号分隔多模型文本输出包含所有模型", async () => { - const result = await runCli([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -132,25 +158,46 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model 文本输出包含正确的 Type 列", async () => { - const result = await runCli(["usage", "free", "--model", "qwen3-max", "--output", "text"]); + const result = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "free", + "--model", + "qwen3-max", + "--output", + "text", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("usage free --model quotaStatus 为 UNKNOWN 时 Auto-Stop 显示 Unsupported", async () => { - const result = await runCli(["usage", "free", "--model", "wan2.7-image", "--output", "text"]); + const result = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "free", + "--model", + "wan2.7-image", + "--output", + "text", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("usage free --model quotaStatus 为 UNKNOWN 时额度显示为 -", async () => { - const result = await runCli(["usage", "free", "--model", "wan2.7-image", "--output", "text"]); + const result = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "free", + "--model", + "wan2.7-image", + "--output", + "text", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("usage free --model 不存在的模型仍返回表格行", async () => { - const result = await runCli([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -163,13 +210,20 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model Auto-Stop 显示 ON、OFF 或 Unsupported", async () => { - const result = await runCli(["usage", "free", "--model", "qwen3-max", "--output", "text"]); + const result = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "free", + "--model", + "qwen3-max", + "--output", + "text", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("usage free --model --console-region cn-beijing 指定区域查询", async () => { - const result = await runCli([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", diff --git a/packages/cli/tests/e2e/usage-stats.e2e.test.ts b/packages/commands/tests/e2e/usage-stats.e2e.test.ts similarity index 81% rename from packages/cli/tests/e2e/usage-stats.e2e.test.ts rename to packages/commands/tests/e2e/usage-stats.e2e.test.ts index cdda8bf..150a6c8 100644 --- a/packages/cli/tests/e2e/usage-stats.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-stats.e2e.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "vite-plus/test"; -import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts"; +import { + isConsoleE2EReady, + isConsoleAuthFailure, + parseStdoutJson, + runCommandE2e, +} from "./helpers.ts"; +import { USAGE_ROUTES } from "./topic-routes.ts"; import { readConfigFile } from "bailian-cli-core"; function getStaticWorkspaceId(): string | undefined { @@ -20,7 +26,7 @@ async function fetchDefaultWorkspaceId(): Promise { const staticId = getStaticWorkspaceId(); if (staticId) return staticId; - const result = await runCli(["workspace", "list", "--output", "json"]); + const result = await runCommandE2e(USAGE_ROUTES, ["workspace", "list", "--output", "json"]); if (isConsoleAuthFailure(result) || result.exitCode !== 0) return FALLBACK_WORKSPACE_ID; try { const parsed = JSON.parse(result.stdout); @@ -36,13 +42,13 @@ async function fetchDefaultWorkspaceId(): Promise { describe("e2e: usage stats", () => { test("usage stats --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["usage", "stats", "--help"]); + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, ["usage", "stats", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--model|--days|stats/i); }); test("usage stats --help 包含所有示例", async () => { - const { stderr, exitCode } = await runCli(["usage", "stats", "--help"]); + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, ["usage", "stats", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toContain("bl usage stats"); expect(stderr).toContain("bl usage stats --model qwen-turbo"); @@ -50,7 +56,7 @@ describe("e2e: usage stats", () => { }); test("usage stats --help 包含 --workspace-id 选项", async () => { - const { stderr, exitCode } = await runCli(["usage", "stats", "--help"]); + const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, ["usage", "stats", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toContain("--workspace-id"); }); @@ -66,7 +72,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --dry-run 概览模式输出请求参数", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -95,7 +101,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --dry-run --days 30 时间跨度约 30 天", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -117,7 +123,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --dry-run --model 指定模型使用 list API", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -139,7 +145,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --dry-run --type Text 传递 obsModelType", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -158,25 +164,46 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats 概览模式返回 JSON 结果", async () => { - const result = await runCli(["usage", "stats", "--workspace-id", wsId, "--output", "json"]); + const result = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "stats", + "--workspace-id", + wsId, + "--output", + "json", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("usage stats 概览文本输出包含英文标签", async () => { - const result = await runCli(["usage", "stats", "--workspace-id", wsId, "--output", "text"]); + const result = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "stats", + "--workspace-id", + wsId, + "--output", + "text", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("usage stats 概览文本输出包含 Token 用量", async () => { - const result = await runCli(["usage", "stats", "--workspace-id", wsId, "--output", "text"]); + const result = await runCommandE2e(USAGE_ROUTES, [ + "usage", + "stats", + "--workspace-id", + wsId, + "--output", + "text", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("usage stats --model 单模型文本输出包含英文表头", async () => { - const result = await runCli([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -191,7 +218,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --model 逗号分隔多模型返回多行", async () => { - const result = await runCli([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -206,7 +233,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --model 不存在的模型返回空表格", async () => { - const result = await runCli([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -221,7 +248,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --days 1 短时间范围正常返回", async () => { - const result = await runCli([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -236,7 +263,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --type Vision 按类型过滤", async () => { - const result = await runCli([ + const result = await runCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", diff --git a/packages/cli/tests/e2e/video-download.e2e.test.ts b/packages/commands/tests/e2e/video-download.e2e.test.ts similarity index 86% rename from packages/cli/tests/e2e/video-download.e2e.test.ts rename to packages/commands/tests/e2e/video-download.e2e.test.ts index 70e79c7..ece742d 100644 --- a/packages/cli/tests/e2e/video-download.e2e.test.ts +++ b/packages/commands/tests/e2e/video-download.e2e.test.ts @@ -8,8 +8,9 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; +import { VIDEO_ROUTES } from "./topic-routes.ts"; /** dry-run 占位 UUID */ const PLACEHOLDER_TASK_ID = "00000000-0000-4000-8000-000000000001"; @@ -20,14 +21,8 @@ const PLACEHOLDER_TASK_ID = "00000000-0000-4000-8000-000000000001"; */ describe("e2e: video download", () => { - test("video 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["video"]); - expect(exitCode, stderr).toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i); - }); - test("video download --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["video", "download", "--help"]); + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, ["video", "download", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/download|--task-id|--out/i); }); @@ -37,7 +32,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "e2e: video download(DashScope 视频)", () => { test("video download 缺少 --task-id 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "download", "--out", @@ -48,7 +43,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( }); test("video download 缺少 --out 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "download", "--task-id", @@ -61,7 +56,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( test("video download --dry-run 仅输出计划且不下载", async () => { const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); const fakeOut = join(outDir, "e2e-dry-not-written.mp4"); - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "download", "--dry-run", @@ -83,7 +78,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); const genMp4 = join(outDir, "e2e-gen-for-download.mp4"); - const gen = await runCli([ + const gen = await runCommandE2e(VIDEO_ROUTES, [ "video", "generate", ...cliTimeoutPrefix(), @@ -111,7 +106,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( expect(existsSync(genMp4)).toBe(true); const downloadMp4 = join(outDir, "e2e-download.mp4"); - const dl = await runCli([ + const dl = await runCommandE2e(VIDEO_ROUTES, [ "video", "download", ...cliTimeoutPrefix(), diff --git a/packages/cli/tests/e2e/video-edit.e2e.test.ts b/packages/commands/tests/e2e/video-edit.e2e.test.ts similarity index 83% rename from packages/cli/tests/e2e/video-edit.e2e.test.ts rename to packages/commands/tests/e2e/video-edit.e2e.test.ts index fa6e9a2..95e526a 100644 --- a/packages/cli/tests/e2e/video-edit.e2e.test.ts +++ b/packages/commands/tests/e2e/video-edit.e2e.test.ts @@ -7,28 +7,23 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; +import { VIDEO_ROUTES } from "./topic-routes.ts"; /** * Video edit E2E */ describe("e2e: video edit", () => { - test("video 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["video"]); - expect(exitCode, stderr).toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i); - }); - test("video edit --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["video", "edit", "--help"]); + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, ["video", "edit", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/edit|--video|--prompt|model|--async|--concurrent/i); }); test("video edit --dry-run 接受 --async 与 --concurrent", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "edit", "--dry-run", @@ -54,7 +49,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "e2e: video edit(DashScope 视频)", () => { test("video edit 缺少 --video 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "edit", ...cliTimeoutPrefix(), @@ -71,7 +66,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); const t2vPath = join(outDir, "e2e-video-t2v.mp4"); - const t2v = await runCli([ + const t2v = await runCommandE2e(VIDEO_ROUTES, [ "video", "generate", ...cliTimeoutPrefix(), @@ -88,7 +83,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( const t2vData = parseStdoutJson<{ status?: string; video_url?: string }>(t2v.stdout); expect(t2vData.status).toBe("SUCCEEDED"); - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "edit", ...cliTimeoutPrefix(), diff --git a/packages/cli/tests/e2e/video-generate-i2v.e2e.test.ts b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts similarity index 84% rename from packages/cli/tests/e2e/video-generate-i2v.e2e.test.ts rename to packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts index 1c54b9d..1363ed3 100644 --- a/packages/cli/tests/e2e/video-generate-i2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-generate-i2v.e2e.test.ts @@ -7,22 +7,17 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; +import { VIDEO_ROUTES } from "./topic-routes.ts"; /** * Video generate (i2v):help / 分组不依赖密钥;长任务需视频 E2E + DashScope。 */ describe("e2e: video generate (i2v)", () => { - test("video 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["video"]); - expect(exitCode, stderr).toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i); - }); - test("video generate --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["video", "generate", "--help"]); + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, ["video", "generate", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/generate|--prompt|--image|model/i); }); @@ -32,7 +27,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "e2e: video generate (i2v)(DashScope 视频)", () => { test("video generate 缺少 --prompt 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "generate", ...cliTimeoutPrefix(), @@ -46,7 +41,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( }); test("video generate --dry-run(无 --image)仅输出 request(t2v 路径不调上传)", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "generate", ...cliTimeoutPrefix(), @@ -69,7 +64,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( test("【happyhorse-1.1-i2v】图片生成视频", async () => { const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); const png = join(outDir, "e2e-gen.png"); - const gen = await runCli([ + const gen = await runCommandE2e(VIDEO_ROUTES, [ "image", "generate", "--model", @@ -87,7 +82,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( const genData = parseStdoutJson<{ saved?: string[] }>(gen.stdout); const imagePath = genData.saved?.[0] ?? png; - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "generate", ...cliTimeoutPrefix(), diff --git a/packages/cli/tests/e2e/video-generate-t2v.e2e.test.ts b/packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts similarity index 81% rename from packages/cli/tests/e2e/video-generate-t2v.e2e.test.ts rename to packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts index 41d8685..e1efdfb 100644 --- a/packages/cli/tests/e2e/video-generate-t2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-generate-t2v.e2e.test.ts @@ -7,22 +7,17 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; +import { VIDEO_ROUTES } from "./topic-routes.ts"; /** * Video generate (t2v):help / 分组不依赖密钥;长任务需视频 E2E + DashScope。 */ describe("e2e: video generate (t2v)", () => { - test("video 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["video"]); - expect(exitCode, stderr).toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i); - }); - test("video generate --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["video", "generate", "--help"]); + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, ["video", "generate", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/generate|--prompt|--model|download|image/i); }); @@ -32,7 +27,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "e2e: video generate (t2v)(DashScope 视频)", () => { test("video generate 缺少 --prompt 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "generate", ...cliTimeoutPrefix(), @@ -44,7 +39,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( }); test("video generate --dry-run(无 --image)仅输出 request 且不调生成接口", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "generate", "--dry-run", @@ -66,7 +61,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( test("【happyhorse-1.1-t2v】文本生成视频", async () => { const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "generate", ...cliTimeoutPrefix(), diff --git a/packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts b/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts similarity index 84% rename from packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts rename to packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts index 55b80a0..9a3af7f 100644 --- a/packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts +++ b/packages/commands/tests/e2e/video-ref-r2v.e2e.test.ts @@ -7,28 +7,23 @@ import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, - runCli, + runCommandE2e, } from "./helpers.ts"; +import { VIDEO_ROUTES } from "./topic-routes.ts"; /** * Video ref (r2v):help / 分组不依赖密钥;参考生成需视频 E2E + DashScope。 */ describe("e2e: video ref (r2v)", () => { - test("video 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["video"]); - expect(exitCode, stderr).toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i); - }); - test("video ref --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["video", "ref", "--help"]); + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, ["video", "ref", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/ref|--prompt|--image|model|--async|--concurrent/i); }); test("video ref --dry-run 接受 --async 与 --concurrent", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "ref", "--dry-run", @@ -54,7 +49,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( "e2e: video ref (r2v)(DashScope 视频)", () => { test("video ref 缺少 --prompt 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "ref", ...cliTimeoutPrefix(), @@ -68,7 +63,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( }); test("video ref 缺少 --image 与 --ref-video 时退出为用法错误 (2)", async () => { - const { stderr, exitCode } = await runCli([ + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "ref", ...cliTimeoutPrefix(), @@ -83,7 +78,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( test("【happyhorse-1.1-r2v】视频参考生成", async () => { const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); - const gen = await runCli([ + const gen = await runCommandE2e(VIDEO_ROUTES, [ "image", "generate", "--model", @@ -102,7 +97,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( const imagePath = genData.saved?.[0]; expect(imagePath).toBeTruthy(); - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "ref", ...cliTimeoutPrefix(), diff --git a/packages/cli/tests/e2e/video-task-get.e2e.test.ts b/packages/commands/tests/e2e/video-task-get.e2e.test.ts similarity index 72% rename from packages/cli/tests/e2e/video-task-get.e2e.test.ts rename to packages/commands/tests/e2e/video-task-get.e2e.test.ts index 0ea0fc7..eeeb0e4 100644 --- a/packages/cli/tests/e2e/video-task-get.e2e.test.ts +++ b/packages/commands/tests/e2e/video-task-get.e2e.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "vite-plus/test"; -import { isBailianE2EEnabled, isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts"; +import { + isBailianE2EEnabled, + isDashScopeE2EReady, + parseStdoutJson, + runCommandE2e, +} from "./helpers.ts"; +import { VIDEO_ROUTES } from "./topic-routes.ts"; const taskId = process.env.BAILIAN_E2E_VIDEO_TASK_ID?.trim(); @@ -8,14 +14,13 @@ const taskId = process.env.BAILIAN_E2E_VIDEO_TASK_ID?.trim(); */ describe("e2e: video task get", () => { - test("video 分组展示子命令帮助且成功退出", async () => { - const { stdout, stderr, exitCode } = await runCli(["video"]); - expect(exitCode, stderr).toBe(0); - expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i); - }); - test("video task get --help 正常退出", async () => { - const { stderr, exitCode } = await runCli(["video", "task", "get", "--help"]); + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ + "video", + "task", + "get", + "--help", + ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/task|get|--task-id/i); }); @@ -25,7 +30,13 @@ describe.skipIf(!isBailianE2EEnabled() || !taskId || !isDashScopeE2EReady())( "e2e: video task get(DashScope)", () => { test("video task get 缺少 --task-id 时报用法错误并退出 (2)", async () => { - const { stderr, exitCode } = await runCli(["video", "task", "get", "--output", "json"]); + const { stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ + "video", + "task", + "get", + "--output", + "json", + ]); expect(exitCode).toBe(2); const err = JSON.parse(stderr.trim()) as { error?: { code?: number; message?: string } }; expect(err.error?.code).toBe(2); @@ -33,7 +44,7 @@ describe.skipIf(!isBailianE2EEnabled() || !taskId || !isDashScopeE2EReady())( }); test("video task get --dry-run 仅回显 task_id 且不调任务接口", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "task", "get", @@ -49,7 +60,7 @@ describe.skipIf(!isBailianE2EEnabled() || !taskId || !isDashScopeE2EReady())( }); test("根据 task_id 查询任务状态", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runCommandE2e(VIDEO_ROUTES, [ "video", "task", "get", diff --git a/packages/commands/tsconfig.json b/packages/commands/tsconfig.json index ff4adab..e2bb481 100644 --- a/packages/commands/tsconfig.json +++ b/packages/commands/tsconfig.json @@ -16,5 +16,6 @@ "isolatedModules": true, "verbatimModuleSyntax": true, "skipLibCheck": true - } + }, + "include": ["src/**/*"] } diff --git a/packages/commands/vite.config.ts b/packages/commands/vite.config.ts index 7550a27..f1a0234 100644 --- a/packages/commands/vite.config.ts +++ b/packages/commands/vite.config.ts @@ -1,6 +1,11 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ + test: { + globalSetup: "../e2e/src/global-setup.ts", + testTimeout: 60_000, + hookTimeout: 60_000, + }, pack: { minify: true, dts: { diff --git a/packages/e2e/package.json b/packages/e2e/package.json new file mode 100644 index 0000000..f1d5540 --- /dev/null +++ b/packages/e2e/package.json @@ -0,0 +1,26 @@ +{ + "name": "e2e", + "version": "0.0.0", + "private": true, + "description": "Shared E2E harness utilities for bailian-cli monorepo (not published).", + "type": "module", + "exports": { + "./gating": "./src/gating.ts", + "./runner": "./src/run-subprocess.ts", + "./output": "./src/output.ts", + "./global-setup": "./src/global-setup.ts", + "./monorepo-root": "./src/monorepo-root.ts", + "./registry-smoke": "./src/registry-smoke.ts" + }, + "dependencies": { + "bailian-cli-core": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "^6.0.2", + "vite-plus": "0.1.22" + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/packages/e2e/src/gating.ts b/packages/e2e/src/gating.ts new file mode 100644 index 0000000..36ade27 --- /dev/null +++ b/packages/e2e/src/gating.ts @@ -0,0 +1,56 @@ +import { readConfigFile } from "bailian-cli-core"; + +/** 显式开启后才跑真实网络 E2E */ +export function isBailianE2EEnabled(): boolean { + return process.env.BAILIAN_E2E === "1"; +} + +/** 可调 DashScope 的 API Key:环境变量优先,否则读 ~/.bailian/config.json */ +export function isDashScopeE2EReady(): boolean { + if (!isBailianE2EEnabled()) return false; + if (process.env.DASHSCOPE_API_KEY?.trim()) return true; + try { + const f = readConfigFile(); + return typeof f.api_key === "string" && f.api_key.length > 0; + } catch { + return false; + } +} + +/** Console-gateway 命令 E2E 就绪检查 */ +export function isConsoleE2EReady(): boolean { + if (!isBailianE2EEnabled()) return false; + try { + const config = readConfigFile(); + return typeof config.access_token === "string" && config.access_token.length > 0; + } catch { + return false; + } +} + +/** 语音与图像(可设 `BAILIAN_E2E_MEDIA=0` 跳过) */ +export function isBailianE2EMediaEnabled(): boolean { + if (process.env.BAILIAN_E2E_MEDIA === "0") return false; + return isBailianE2EEnabled(); +} + +/** 文生视频 / 图生视频等(耗时长,默认关闭) */ +export function isBailianE2EVideoEnabled(): boolean { + return isBailianE2EEnabled() && process.env.BAILIAN_E2E_VIDEO === "1"; +} + +/** 知识检索 E2E 就绪 */ +export function isSearchE2EReady(): boolean { + if (!isDashScopeE2EReady()) return false; + return ( + !!process.env.BAILIAN_E2E_SEARCH_AGENT_ID?.trim() && !!process.env.BAILIAN_WORKSPACE_ID?.trim() + ); +} + +/** 知识问答 E2E 就绪 */ +export function isChatE2EReady(): boolean { + if (!isDashScopeE2EReady()) return false; + return ( + !!process.env.BAILIAN_E2E_CHAT_AGENT_ID?.trim() && !!process.env.BAILIAN_WORKSPACE_ID?.trim() + ); +} diff --git a/packages/cli/tests/e2e/global-setup.ts b/packages/e2e/src/global-setup.ts similarity index 65% rename from packages/cli/tests/e2e/global-setup.ts rename to packages/e2e/src/global-setup.ts index de36bd8..f12704c 100644 --- a/packages/cli/tests/e2e/global-setup.ts +++ b/packages/e2e/src/global-setup.ts @@ -1,16 +1,17 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs"; import { join } from "path"; import { parseEnv } from "util"; -import { E2E_RUN_SESSION_FILENAME, monorepoRoot } from "./helpers.ts"; +import { monorepoRoot } from "./monorepo-root.ts"; + +/** Vitest globalSetup 写入的会话标记文件名 */ +export const E2E_RUN_SESSION_FILENAME = ".e2e-run-session"; /** - * Vitest 在所有 worker 启动前执行一次:写入共享会话 id,使多进程并行时仍共用一个 `test/output/<会话>/`。 - * 结束后删除标记文件,避免非 Vitest 流程误用上一次会话 id。 + * Vitest 在所有 worker 启动前执行一次:加载 `.env` 并写入共享 E2E 输出会话 id。 */ - export default function vitestGlobalSetup(): () => void { - // 加载根目录 `.env` 合并变量(包含shell 注入) - const rootEnv = join(monorepoRoot(), ".env"); + const root = monorepoRoot(); + const rootEnv = join(root, ".env"); if (existsSync(rootEnv)) { const parsed = parseEnv(readFileSync(rootEnv, "utf8")); Object.assign(process.env, parsed); @@ -18,34 +19,36 @@ export default function vitestGlobalSetup(): () => void { process.env.BAILIAN_E2E = "1"; process.env.BAILIAN_E2E_MEDIA = "1"; process.env.BAILIAN_E2E_VIDEO = "1"; - // 如根目录不存在 .env,则生成一个 .env const envContent = `# 是否开启 E2E 测试 BAILIAN_E2E=1 # 是否开启图片/语音 E2E 测试 BAILIAN_E2E_MEDIA=1 # 是否开启视频 E2E 测试 BAILIAN_E2E_VIDEO=1 +# DashScope Base URL +DASHSCOPE_BASE_URL= # DashScope API Key DASHSCOPE_API_KEY= # ------------------------------- BAILIAN_E2E_VIDEO_TASK_ID=b499a8cb-1fc4-4d43-9495-e23c7f78ae0d # ------------------------------- -# 知识库 ID +# Workspace ID BAILIAN_WORKSPACE_ID= -# 索引 ID -BAILIAN_E2E_INDEX_ID= # ------------------------------- - `; +# 知识库检索Agent ID +BAILIAN_E2E_SEARCH_AGENT_ID= +# 知识库问答Agent ID +BAILIAN_E2E_CHAT_AGENT_ID= +`; writeFileSync(rootEnv, envContent, "utf8"); } - // 创建生成内容目录 const now = new Date(); const pad = (n: number) => n.toString().padStart(2, "0"); const dateStr = [now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())].join("-"); const timeStr = [pad(now.getHours()), pad(now.getMinutes()), pad(now.getSeconds())].join(":"); const runId = `e2e-run-${dateStr} ${timeStr}`; - const outDir = join(monorepoRoot(), "test", "output"); + const outDir = join(root, "test", "output"); mkdirSync(outDir, { recursive: true }); const marker = join(outDir, E2E_RUN_SESSION_FILENAME); writeFileSync(marker, `${runId}\n`, "utf8"); @@ -53,7 +56,7 @@ BAILIAN_E2E_INDEX_ID= try { unlinkSync(marker); } catch { - /* 忽略:已删或权限等 */ + /* 忽略 */ } }; } diff --git a/packages/e2e/src/monorepo-root.ts b/packages/e2e/src/monorepo-root.ts new file mode 100644 index 0000000..5676bd2 --- /dev/null +++ b/packages/e2e/src/monorepo-root.ts @@ -0,0 +1,15 @@ +import { existsSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +/** Monorepo 根目录(含根 `package.json`) */ +export function monorepoRoot(fromModuleUrl: string = import.meta.url): string { + let dir = dirname(fileURLToPath(fromModuleUrl)); + for (let i = 0; i < 8; i++) { + if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error("Could not locate monorepo root (pnpm-workspace.yaml)"); +} diff --git a/packages/e2e/src/output.ts b/packages/e2e/src/output.ts new file mode 100644 index 0000000..385299d --- /dev/null +++ b/packages/e2e/src/output.ts @@ -0,0 +1,77 @@ +import { mkdirSync, readFileSync } from "fs"; +import { basename, join } from "path"; +import { fileURLToPath } from "url"; +import { E2E_RUN_SESSION_FILENAME } from "./global-setup.ts"; +import { monorepoRoot } from "./monorepo-root.ts"; +import type { RunCliResult } from "./run-subprocess.ts"; + +let e2eOutputSessionId: string | undefined; + +function readE2eRunSessionFromOutputDir(): string | undefined { + try { + const p = join(monorepoRoot(), "test", "output", E2E_RUN_SESSION_FILENAME); + const t = readFileSync(p, "utf8").trim(); + return t.length > 0 ? t : undefined; + } catch { + return undefined; + } +} + +function getE2eOutputSessionId(): string { + if (!e2eOutputSessionId) { + const fromEnv = process.env.BAILIAN_E2E_RUN_ID?.trim(); + if (fromEnv) { + e2eOutputSessionId = fromEnv.replace(/[^a-zA-Z0-9._-]+/g, "-"); + } else { + const fromFile = readE2eRunSessionFromOutputDir(); + if (fromFile) { + e2eOutputSessionId = fromFile.replace(/[^a-zA-Z0-9._-]+/g, "-"); + } else { + e2eOutputSessionId = `e2e-run-${Date.now()}-${process.pid}`; + } + } + } + return e2eOutputSessionId; +} + +/** 在 `test/output/<会话>/` 下创建用例子目录 */ +export function makeE2eOutputDir(label: string): string { + const fromEnv = process.env.BAILIAN_E2E_OUT?.trim(); + if (fromEnv) { + mkdirSync(fromEnv, { recursive: true }); + return fromEnv; + } + const safe = label.replace(/[^a-zA-Z0-9._-]+/g, "-"); + const sessionDir = join(monorepoRoot(), "test", "output", getE2eOutputSessionId()); + mkdirSync(sessionDir, { recursive: true }); + const dir = join(sessionDir, `e2e-vp-${safe}-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +/** 全局 `--timeout` 秒数(视频等长任务) */ +export function cliTimeoutSeconds(): string { + return process.env.BAILIAN_E2E_TIMEOUT_SEC?.trim() || "3600"; +} + +export function cliTimeoutPrefix(): string[] { + return ["--timeout", cliTimeoutSeconds()]; +} + +/** 从 `import.meta.url` 生成 OUT 子目录标签 */ +export function e2eLabelFromMetaUrl(metaUrl: string): string { + return basename(fileURLToPath(metaUrl), ".ts").replace(/\.e2e\.test$/, ""); +} + +export function parseStdoutJson(stdout: string): T { + const t = stdout.trim(); + const jsonMatch = t.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error(`No JSON object found in stdout: ${t.slice(0, 200)}`); + return JSON.parse(jsonMatch[0]) as T; +} + +/** Console session 未登录/已过期时的优雅失败判定 */ +export function isConsoleAuthFailure(result: RunCliResult): boolean { + if (result.exitCode === 0) return false; + return /not logged in|has expired|NotLogined|Run `bl auth login/i.test(result.stderr); +} diff --git a/packages/e2e/src/registry-smoke.ts b/packages/e2e/src/registry-smoke.ts new file mode 100644 index 0000000..bed14cc --- /dev/null +++ b/packages/e2e/src/registry-smoke.ts @@ -0,0 +1,18 @@ +/** + * 从产品 commands map 的 path key 推导有子命令的分组前缀。 + * 供 cli / kscli registry smoke 共用。 + */ +export function deriveGroupPaths(commandPaths: string[]): string[] { + const groups = new Set(); + for (const path of commandPaths) { + const parts = path.split(" "); + for (let i = 1; i < parts.length; i++) { + const prefix = parts.slice(0, i).join(" "); + const hasChildren = commandPaths.some( + (candidate) => candidate.startsWith(`${prefix} `) && candidate !== prefix, + ); + if (hasChildren) groups.add(prefix); + } + } + return [...groups].sort(); +} diff --git a/packages/e2e/src/run-subprocess.ts b/packages/e2e/src/run-subprocess.ts new file mode 100644 index 0000000..6d41cd2 --- /dev/null +++ b/packages/e2e/src/run-subprocess.ts @@ -0,0 +1,52 @@ +import { execFile } from "child_process"; +import { join } from "path"; +import { promisify } from "util"; +import { monorepoRoot } from "./monorepo-root.ts"; + +const execFileAsync = promisify(execFile); + +export interface RunCliResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** monorepo 根目录下的 tsx 可执行文件 */ +export function resolveTsxBin(): string { + const root = monorepoRoot(); + const name = process.platform === "win32" ? "tsx.cmd" : "tsx"; + return join(root, "node_modules", ".bin", name); +} + +/** 子进程通过 tsx 执行指定 main.ts 入口 */ +export async function runNodeMain( + mainTs: string, + args: string[], + options: { cwd: string; env?: NodeJS.ProcessEnv } = { cwd: process.cwd() }, +): Promise { + try { + const { stdout, stderr } = await execFileAsync(resolveTsxBin(), [mainTs, ...args], { + cwd: options.cwd, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + env: { + ...process.env, + NODE_NO_WARNINGS: "1", + DO_NOT_TRACK: "1", + ...options.env, + }, + }); + return { stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 }; + } catch (err: unknown) { + const e = err as { + stdout?: string; + stderr?: string; + code?: number; + }; + return { + stdout: e.stdout ?? "", + stderr: e.stderr ?? "", + exitCode: typeof e.code === "number" ? e.code : 1, + }; + } +} diff --git a/packages/e2e/vite.config.ts b/packages/e2e/vite.config.ts new file mode 100644 index 0000000..168a023 --- /dev/null +++ b/packages/e2e/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + lint: { + options: { + typeAware: true, + typeCheck: true, + }, + }, + fmt: {}, +}); diff --git a/packages/kscli/package.json b/packages/kscli/package.json index e1bcbd1..ba33d74 100644 --- a/packages/kscli/package.json +++ b/packages/kscli/package.json @@ -59,6 +59,7 @@ "ajv": "catalog:", "boxen": "catalog:", "chalk": "catalog:", + "e2e": "workspace:*", "typescript": "^6.0.2", "undici": "catalog:", "vite-plus": "0.1.22", diff --git a/packages/kscli/src/commands.ts b/packages/kscli/src/commands.ts new file mode 100644 index 0000000..ad49dff --- /dev/null +++ b/packages/kscli/src/commands.ts @@ -0,0 +1,23 @@ +import type { AnyCommand } from "bailian-cli-core"; +import { + configShow, + configSet, + update, + knowledgeRetrieve, + knowledgeSearch, + knowledgeChat, +} from "bailian-cli-commands"; + +// kscli (Knowledge Studio CLI): lightweight RAG product. Ships config/update +// plus the knowledge commands, remapped to flat paths. Routing is driven +// entirely by these keys, and usage/examples/errors render the path from the +// key — so the same shared command shows `kscli search` here and +// `bl knowledge search` in bl. +export const commands: Record = { + "config show": configShow, + "config set": configSet, + update, + retrieve: knowledgeRetrieve, + search: knowledgeSearch, + chat: knowledgeChat, +}; diff --git a/packages/kscli/src/main.ts b/packages/kscli/src/main.ts index f0bd202..fbd0f04 100644 --- a/packages/kscli/src/main.ts +++ b/packages/kscli/src/main.ts @@ -1,29 +1,7 @@ import { createCli } from "bailian-cli-runtime"; -import type { AnyCommand } from "bailian-cli-core"; -import { - configShow, - configSet, - update, - knowledgeRetrieve, - knowledgeSearch, - knowledgeChat, -} from "bailian-cli-commands"; +import { commands } from "./commands.ts"; import pkg from "../package.json" with { type: "json" }; -// kscli (Knowledge Studio CLI): lightweight RAG product. Ships config/update -// plus the knowledge commands, remapped to flat paths. Routing is driven -// entirely by these keys, and usage/examples/errors render the path from the -// key — so the same shared command shows `kscli search` here and -// `bl knowledge search` in bl. -const commands: Record = { - "config show": configShow, - "config set": configSet, - update, - retrieve: knowledgeRetrieve, - search: knowledgeSearch, - chat: knowledgeChat, -}; - void createCli(commands, { binName: "kscli", version: pkg.version, diff --git a/packages/kscli/tests/e2e/chat.e2e.test.ts b/packages/kscli/tests/e2e/chat.e2e.test.ts deleted file mode 100644 index ed4535f..0000000 --- a/packages/kscli/tests/e2e/chat.e2e.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, test } from "vite-plus/test"; -import { isChatE2EReady, parseStdoutJson, runKscli } from "./helpers.ts"; - -// ---- Types ---- - -interface ChatJsonResult { - answer: string; - request_id: string; -} - -// ---- Real API call tests (gated by BAILIAN_E2E + credentials) ---- - -describe.skipIf(!isChatE2EReady())("e2e: kscli chat (live)", () => { - const agentId = process.env.BAILIAN_E2E_CHAT_AGENT_ID!; - const workspaceId = process.env.BAILIAN_WORKSPACE_ID!; - - test("chat (JSON mode) returns answer", async () => { - const { stdout, stderr, exitCode } = await runKscli([ - "chat", - "--message", - "什么是大模型?", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--output", - "json", - ]); - - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson(stdout); - expect(data.answer).toBeTruthy(); - expect(data.answer.length).toBeGreaterThan(0); - expect(data.request_id).toBeTruthy(); - }); - - test("chat (text mode) returns plain text", async () => { - const { stdout, stderr, exitCode } = await runKscli([ - "chat", - "--message", - "什么是RAG?", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--output", - "text", - ]); - - expect(exitCode, stderr).toBe(0); - expect(stdout.trim().length).toBeGreaterThan(0); - }); - - test("chat (stream, JSON mode) collects and returns answer", async () => { - const { stdout, stderr, exitCode } = await runKscli([ - "chat", - "--message", - "什么是检索增强生成?", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--output", - "json", - ]); - - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson(stdout); - expect(data.answer).toBeTruthy(); - expect(data.answer.length).toBeGreaterThan(0); - expect(data.request_id).toBeTruthy(); - }); - - test("chat (stream, text mode) outputs streaming text", async () => { - const { stdout, stderr, exitCode } = await runKscli([ - "chat", - "--message", - "什么是向量检索?", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--output", - "text", - ]); - - expect(exitCode, stderr).toBe(0); - // Streaming text mode: output should contain some text content - expect(stdout.trim().length).toBeGreaterThan(0); - }); - - test("chat with multi-turn messages returns context-aware answer", async () => { - const { stdout, stderr, exitCode } = await runKscli([ - "chat", - "--message", - "user:什么是大模型", - "--message", - "assistant:大模型是大规模语言模型,具有强大的理解和生成能力", - "--message", - "它有哪些应用场景?", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--output", - "json", - ]); - - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson(stdout); - expect(data.answer).toBeTruthy(); - expect(data.answer.length).toBeGreaterThan(0); - }); - - test("chat with invalid agent_id fails gracefully", async () => { - const { stderr, exitCode } = await runKscli([ - "chat", - "--message", - "test", - "--agent-id", - "aid-invalid-not-exist", - "--workspace-id", - workspaceId, - "--output", - "json", - ]); - - expect(exitCode).not.toBe(0); - expect(stderr).toBeTruthy(); - }); -}); diff --git a/packages/kscli/tests/e2e/global-setup.ts b/packages/kscli/tests/e2e/global-setup.ts deleted file mode 100644 index fc93de0..0000000 --- a/packages/kscli/tests/e2e/global-setup.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { loadRootEnv } from "./helpers.ts"; - -/** - * Vitest globalSetup: load monorepo root `.env` into `process.env` before tests run. - */ -export default function vitestGlobalSetup(): () => void { - loadRootEnv(); - return () => {}; -} diff --git a/packages/kscli/tests/e2e/helpers.ts b/packages/kscli/tests/e2e/helpers.ts index 6e6d885..65e56b3 100644 --- a/packages/kscli/tests/e2e/helpers.ts +++ b/packages/kscli/tests/e2e/helpers.ts @@ -1,138 +1,42 @@ -import { execFile } from "child_process"; -import { existsSync, mkdtempSync, readFileSync } from "fs"; +import { mkdtempSync } from "fs"; import { tmpdir } from "os"; -import { promisify } from "util"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import { parseEnv } from "util"; - -const execFileAsync = promisify(execFile); - -/** `packages/kscli` 根目录(含 `src/main.ts`) */ +import { parseStdoutJson } from "e2e/output"; +import { runNodeMain, type RunCliResult } from "e2e/runner"; +import { + isBailianE2EEnabled, + isChatE2EReady, + isDashScopeE2EReady, + isSearchE2EReady, +} from "e2e/gating"; +import { monorepoRoot } from "e2e/monorepo-root"; + +export { + isBailianE2EEnabled, + isChatE2EReady, + isDashScopeE2EReady, + isSearchE2EReady, + monorepoRoot, + parseStdoutJson, +}; +export type { RunCliResult }; + +/** `packages/kscli` 根目录 */ export const kscliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const mainTs = join(kscliPackageRoot, "src", "main.ts"); -/** Monorepo 根(含根 `package.json` 和 `.env`) */ -export function monorepoRoot(): string { - return join(kscliPackageRoot, "..", ".."); -} - -function localBin(name: string): string { - return join( - monorepoRoot(), - "node_modules", - ".bin", - process.platform === "win32" ? `${name}.cmd` : name, - ); -} - -// ---- E2E gating helpers ---- - -// ---- .env loader (cached) ---- - -let _rootEnvCache: Record | null = null; - -/** 读取 monorepo 根目录 `.env` 并缓存(.env 值优先于 shell 环境变量) */ -function getRootEnv(): Record { - if (_rootEnvCache !== null) return _rootEnvCache; - const rootEnvPath = join(monorepoRoot(), ".env"); - _rootEnvCache = existsSync(rootEnvPath) ? parseEnv(readFileSync(rootEnvPath, "utf8")) : {}; - return _rootEnvCache; -} - -/** 从 .env 或 process.env 获取值(.env 优先) */ -function envVar(key: string): string | undefined { - return getRootEnv()[key] ?? process.env[key]; -} - -// ---- E2E gating helpers ---- - -/** 显式开启后才跑真实网络 E2E */ -export function isBailianE2EEnabled(): boolean { - return envVar("BAILIAN_E2E") === "1"; -} - -/** 是否有 DashScope API Key 可用 */ -export function isDashScopeE2EReady(): boolean { - if (!isBailianE2EEnabled()) return false; - return !!envVar("DASHSCOPE_API_KEY")?.trim(); -} - -/** 知识检索 E2E 就绪:E2E 开启 + API Key + search agent ID + workspace ID */ -export function isSearchE2EReady(): boolean { - if (!isDashScopeE2EReady()) return false; - return ( - !!envVar("BAILIAN_E2E_SEARCH_AGENT_ID")?.trim() && !!envVar("BAILIAN_WORKSPACE_ID")?.trim() - ); -} - -/** 知识问答 E2E 就绪:E2E 开启 + API Key + chat agent ID + workspace ID */ -export function isChatE2EReady(): boolean { - if (!isDashScopeE2EReady()) return false; - return !!envVar("BAILIAN_E2E_CHAT_AGENT_ID")?.trim() && !!envVar("BAILIAN_WORKSPACE_ID")?.trim(); -} - -// ---- CLI runner ---- - -export interface RunCliResult { - stdout: string; - stderr: string; - exitCode: number; -} - -/** - * 子进程执行 kscli(等价于 `tsx packages/kscli/src/main.ts ...`)。 - */ +/** 子进程执行 kscli */ export async function runKscli( args: string[], envOverrides: NodeJS.ProcessEnv = {}, ): Promise { - try { - const { stdout, stderr } = await execFileAsync(localBin("tsx"), [mainTs, ...args], { - cwd: kscliPackageRoot, - encoding: "utf8", - maxBuffer: 32 * 1024 * 1024, - env: { - ...process.env, - // .env values override shell env vars (ensures correct API key is used) - ...getRootEnv(), - // Unique clean config dir per run — prevents stale config.json from previous tests - BAILIAN_CONFIG_DIR: mkdtempSync(join(tmpdir(), "kscli-test-")), - NODE_NO_WARNINGS: "1", - DO_NOT_TRACK: "1", - ...envOverrides, - }, - }); - return { stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 }; - } catch (err: unknown) { - const e = err as { - stdout?: string; - stderr?: string; - code?: number; - }; - return { - stdout: e.stdout ?? "", - stderr: e.stderr ?? "", - exitCode: typeof e.code === "number" ? e.code : 1, - }; - } -} - -export function parseStdoutJson(stdout: string): T { - const t = stdout.trim(); - return JSON.parse(t) as T; -} - -// ---- Global setup: load root .env ---- - -/** - * Vitest globalSetup:加载 monorepo 根目录 `.env` 合并到 `process.env`。 - */ -export function loadRootEnv(): void { - const rootEnv = join(monorepoRoot(), ".env"); - if (existsSync(rootEnv)) { - const parsed = parseEnv(readFileSync(rootEnv, "utf8")); - Object.assign(process.env, parsed); - } + return runNodeMain(mainTs, args, { + cwd: kscliPackageRoot, + env: { + BAILIAN_CONFIG_DIR: mkdtempSync(join(tmpdir(), "kscli-test-")), + ...envOverrides, + }, + }); } diff --git a/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts b/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts new file mode 100644 index 0000000..892aa1f --- /dev/null +++ b/packages/kscli/tests/e2e/registry.smoke.e2e.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vite-plus/test"; +import { deriveGroupPaths } from "e2e/registry-smoke"; +import pkg from "../../package.json" with { type: "json" }; +import { commands } from "../../src/commands.ts"; +import { runKscli } from "./helpers.ts"; + +const commandPaths = Object.keys(commands).sort(); +const groupPaths = deriveGroupPaths(commandPaths); + +describe("e2e: kscli registry smoke", () => { + test("根帮助展示 kscli 与全局 flag", async () => { + const { stderr, exitCode } = await runKscli(["--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/\bkscli\b/i); + expect(stderr).toMatch(/--base-url/); + expect(stderr).not.toMatch(/^\s*--region\s/m); + }); + + test("--version 输出产品名与版本", async () => { + const { stdout, exitCode } = await runKscli(["--version"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(`kscli ${pkg.version}`); + }); + + test("search --help 展示 kscli 路径与 knowledge 必填 flag", async () => { + const { stderr, exitCode } = await runKscli(["search", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/kscli search/i); + expect(stderr).toMatch(/--query/i); + expect(stderr).toMatch(/--agent-id/i); + expect(stderr).toMatch(/--workspace-id/i); + expect(stderr).not.toMatch(/bl knowledge search/i); + }); + + test("search 缺少 --query 时报用法错误 (2)", async () => { + const { stderr, exitCode } = await runKscli(["search", "--agent-id", "aid_test"]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--query|Missing required/i); + }); + + test.each(commandPaths)("已注册命令 %s --help 成功", async (path) => { + const { stderr, exitCode } = await runKscli([...path.split(" "), "--help"]); + expect(exitCode, stderr).toBe(0); + }); + + test.each(groupPaths)("命令分组 %s --help 成功", async (path) => { + const { stderr, exitCode } = await runKscli([...path.split(" "), "--help"]); + expect(exitCode, stderr).toBe(0); + }); +}); diff --git a/packages/kscli/tests/e2e/search.e2e.test.ts b/packages/kscli/tests/e2e/search.e2e.test.ts deleted file mode 100644 index 93f5b6f..0000000 --- a/packages/kscli/tests/e2e/search.e2e.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, test } from "vite-plus/test"; -import { isSearchE2EReady, parseStdoutJson, runKscli } from "./helpers.ts"; - -// ---- Types ---- - -interface SearchResponse { - code: string; - status_code: number; - request_id: string; - data: { - total: number; - cost_time: number; - nodes: Array<{ - score: number; - text: string; - metadata: { - content?: string; - title?: string; - doc_id?: string; - doc_name?: string; - doc_url?: string; - pipeline_id?: string; - workspace_id?: string; - page_number?: number; - image_url?: string; - _knowledge_type?: string; - _citation_index?: number; - _score?: number; - }; - }>; - }; -} - -// ---- Real API call tests (gated by BAILIAN_E2E + credentials) ---- - -describe.skipIf(!isSearchE2EReady())("e2e: kscli search (live)", () => { - const agentId = process.env.BAILIAN_E2E_SEARCH_AGENT_ID!; - const workspaceId = process.env.BAILIAN_WORKSPACE_ID!; - - test("search returns results in JSON mode", async () => { - const { stdout, stderr, exitCode } = await runKscli([ - "search", - "--query", - "什么是大模型", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--output", - "json", - ]); - - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson(stdout); - expect(data.code).toBe("Success"); - expect(data.request_id).toBeTruthy(); - expect(data.data.total).toBeGreaterThan(0); - expect(data.data.nodes.length).toBeGreaterThan(0); - - const firstNode = data.data.nodes[0]!; - expect(typeof firstNode.score).toBe("number"); - expect(firstNode.score).toBeGreaterThanOrEqual(0); - expect(typeof firstNode.text).toBe("string"); - expect(firstNode.text.length).toBeGreaterThan(0); - }); - - test("search returns results in text mode", async () => { - const { stdout, stderr, exitCode } = await runKscli([ - "search", - "--query", - "RAG", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--output", - "text", - ]); - - expect(exitCode, stderr).toBe(0); - // Text mode: [1] (score: 0.xxxx) followed by text content - expect(stdout).toMatch(/\[1\].*score/); - }); - - test("search with --query-history returns results", async () => { - const { stdout, stderr, exitCode } = await runKscli([ - "search", - "--query", - "它怎么工作", - "--agent-id", - agentId, - "--workspace-id", - workspaceId, - "--query-history", - '[{"role":"user","content":"什么是大模型"},{"role":"assistant","content":"大模型是大规模语言模型"}]', - "--output", - "json", - ]); - - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson(stdout); - expect(data.code).toBe("Success"); - expect(data.data.nodes.length).toBeGreaterThan(0); - }); - - test("search with invalid agent_id fails gracefully", async () => { - const { stderr, exitCode } = await runKscli([ - "search", - "--query", - "test", - "--agent-id", - "aid-invalid-not-exist", - "--workspace-id", - workspaceId, - "--output", - "json", - ]); - - expect(exitCode).not.toBe(0); - expect(stderr).toBeTruthy(); - }); -}); diff --git a/packages/kscli/vite.config.ts b/packages/kscli/vite.config.ts index 3bfbf90..f8bef24 100644 --- a/packages/kscli/vite.config.ts +++ b/packages/kscli/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ test: { - globalSetup: "./tests/e2e/global-setup.ts", + globalSetup: "../e2e/src/global-setup.ts", testTimeout: 60_000, hookTimeout: 60_000, }, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 4681224..20a5401 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -50,6 +50,7 @@ "@types/node": "catalog:", "@typescript/native-preview": "7.0.0-dev.20260328.1", "ajv": "catalog:", + "e2e": "workspace:*", "typescript": "^6.0.2", "vite-plus": "0.1.22", "yaml": "catalog:" diff --git a/packages/runtime/tests/console-flags.e2e.test.ts b/packages/runtime/tests/console-flags.e2e.test.ts new file mode 100644 index 0000000..66ee1e2 --- /dev/null +++ b/packages/runtime/tests/console-flags.e2e.test.ts @@ -0,0 +1,42 @@ +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { describe, expect, test } from "vite-plus/test"; +import { runNodeMain } from "e2e/runner"; + +const runtimeRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const harnessMainTs = join(runtimeRoot, "tests/harness/cross-domain-main.ts"); + +async function runRuntimeHarness(args: string[]) { + return runNodeMain(harnessMainTs, args, { cwd: runtimeRoot }); +} + +/** + * 跨域 flag 拒绝:runtime 凭证域与命令 flag 解析边界(synthetic command,不依赖 commands)。 + */ +describe("e2e: console global flags (cross-domain rejection)", () => { + test("跨域 flag 拒绝:model 命令传 --console-region 报 Unknown flag", async () => { + const { stderr, exitCode } = await runRuntimeHarness([ + "text", + "chat", + "--message", + "hi", + "--console-region", + "cn-hangzhou", + "--dry-run", + ]); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/Unknown flag.*--console-region/); + }); + + test("跨域 flag 拒绝:console 命令传 --api-key 报 Unknown flag", async () => { + const { stderr, exitCode } = await runRuntimeHarness([ + "mcp", + "list", + "--api-key", + "sk-test", + "--dry-run", + ]); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/Unknown flag.*--api-key/); + }); +}); diff --git a/packages/runtime/tests/harness/cross-domain-main.ts b/packages/runtime/tests/harness/cross-domain-main.ts new file mode 100644 index 0000000..c585486 --- /dev/null +++ b/packages/runtime/tests/harness/cross-domain-main.ts @@ -0,0 +1,33 @@ +import { defineCommand } from "bailian-cli-core"; +import { createCli } from "bailian-cli-runtime"; +import pkg from "../../package.json" with { type: "json" }; + +const noop = async () => {}; + +/** apiKey 域 stub:用于跨域 flag 拒绝测试 */ +const textChat = defineCommand({ + description: "runtime e2e stub", + auth: "apiKey", + flags: { + message: { type: "string", valueHint: "", description: "message" }, + }, + run: noop, +}); + +/** console 域 stub:用于跨域 flag 拒绝测试 */ +const mcpList = defineCommand({ + description: "runtime e2e stub", + auth: "console", + flags: {}, + run: noop, +}); + +void createCli( + { "text chat": textChat, "mcp list": mcpList }, + { + binName: "bl", + version: pkg.version, + clientName: "runtime-e2e", + npmPackage: "bailian-cli-runtime-e2e-harness", + }, +).run(); diff --git a/packages/cli/tests/e2e/proxy.e2e.test.ts b/packages/runtime/tests/proxy.e2e.test.ts similarity index 67% rename from packages/cli/tests/e2e/proxy.e2e.test.ts rename to packages/runtime/tests/proxy.e2e.test.ts index b0258eb..dbd2ae3 100644 --- a/packages/cli/tests/e2e/proxy.e2e.test.ts +++ b/packages/runtime/tests/proxy.e2e.test.ts @@ -3,37 +3,31 @@ import { createServer, type Server } from "http"; import { mkdtempSync, rmSync, writeFileSync } from "fs"; import type { AddressInfo } from "net"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; import { promisify } from "util"; import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test"; -import { cliPackageRoot, localBin } from "./helpers.ts"; +import { resolveTsxBin } from "e2e/runner"; const execFileAsync = promisify(execFile); +const runtimePackageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + /** - * 代理支持 E2E:只验证 `setupProxyFromEnv()` 是否把代理 dispatcher - * 正确装到全局 fetch 上——设了 HTTPS_PROXY 后裸 `fetch()` 走代理,未设置时直连, - * NO_PROXY 命中时跳过,非法代理值给出明确报错。 - * - * 不经过任何 CLI 命令(不解析凭证、不打 gateway),因此 CI 上无需 api key / - * access token,与既有 e2e 设计一致。全程离线:目标域名用 `.invalid`(保留顶级域, - * 必然无法解析),代理收到 CONNECT 后规范返回 502,不产生真实外网请求。 + * 代理支持 E2E:验证 `setupProxyFromEnv()` 是否把代理 dispatcher 正确装到全局 fetch 上。 + * 不经过 CLI 命令,CI 无需 api key。 */ const FAKE_HOST = "bl-proxy-e2e.invalid"; const FAKE_URL = `https://${FAKE_HOST}/probe`; -/** - * 最小探针脚本:调用真实的 `setupProxyFromEnv()`,再对目标发一个普通 fetch。 - * 代理行为由进程环境变量决定,正是被测对象;fetch 成败不重要,我们只看代理是否收到 CONNECT。 - */ const PROBE_SCRIPT = ` -import { setupProxyFromEnv } from ${JSON.stringify(join(cliPackageRoot, "..", "runtime", "src", "proxy.ts"))}; +import { setupProxyFromEnv } from ${JSON.stringify(join(runtimePackageRoot, "src", "proxy.ts"))}; setupProxyFromEnv(); try { await fetch(${JSON.stringify(FAKE_URL)}, { signal: AbortSignal.timeout(5000) }); } catch { - // 目标不可达/隧道被拒都正常——本测试只关心代理是否收到 CONNECT + // 目标不可达/隧道被拒都正常 } `; @@ -45,7 +39,6 @@ const connectTargets: string[] = []; beforeAll(async () => { proxy = createServer(); - // 记录收到的 CONNECT 目标(host:port),并以 502 拒绝隧道 proxy.on("connect", (req, clientSocket) => { connectTargets.push(req.url ?? ""); clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); @@ -63,20 +56,21 @@ afterAll(async () => { rmSync(scriptDir, { recursive: true, force: true }); }); -/** 清空所有代理相关环境变量,确保每个用例只受自身设置影响 */ const PROXY_ENV_CLEARED = { HTTPS_PROXY: "", + https_proxy: "", HTTP_PROXY: "", + http_proxy: "", NO_PROXY: "", + no_proxy: "", }; -/** 以给定代理环境变量运行探针脚本,返回 { exitCode, stderr } */ async function runProbe( envOverrides: NodeJS.ProcessEnv, ): Promise<{ exitCode: number; stderr: string }> { try { - await execFileAsync(localBin("tsx"), [scriptPath], { - cwd: cliPackageRoot, + await execFileAsync(resolveTsxBin(), [scriptPath], { + cwd: runtimePackageRoot, encoding: "utf8", env: { ...process.env, NODE_NO_WARNINGS: "1", ...PROXY_ENV_CLEARED, ...envOverrides }, }); @@ -94,6 +88,12 @@ describe("e2e: proxy", () => { expect(connectTargets).toContain(`${FAKE_HOST}:443`); }); + test("空字符串小写变量不屏蔽大写 HTTPS_PROXY(undici ?? 取值回归)", async () => { + connectTargets.length = 0; + await runProbe({ https_proxy: "", HTTPS_PROXY: proxyUrl }); + expect(connectTargets).toContain(`${FAKE_HOST}:443`); + }); + test("NO_PROXY 命中目标主机时不走代理", async () => { connectTargets.length = 0; await runProbe({ HTTPS_PROXY: proxyUrl, NO_PROXY: FAKE_HOST }); diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json index ff4adab..e2bb481 100644 --- a/packages/runtime/tsconfig.json +++ b/packages/runtime/tsconfig.json @@ -16,5 +16,6 @@ "isolatedModules": true, "verbatimModuleSyntax": true, "skipLibCheck": true - } + }, + "include": ["src/**/*"] } diff --git a/packages/runtime/vite.config.ts b/packages/runtime/vite.config.ts index 7550a27..f1a0234 100644 --- a/packages/runtime/vite.config.ts +++ b/packages/runtime/vite.config.ts @@ -1,6 +1,11 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ + test: { + globalSetup: "../e2e/src/global-setup.ts", + testTimeout: 60_000, + hookTimeout: 60_000, + }, pack: { minify: true, dts: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59f01e9..a434066 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ importers: chalk: specifier: 'catalog:' version: 5.6.2 + e2e: + specifier: workspace:* + version: link:../e2e typescript: specifier: ^6.0.2 version: 6.0.3 @@ -119,6 +122,9 @@ importers: '@typescript/native-preview': specifier: 7.0.0-dev.20260328.1 version: 7.0.0-dev.20260328.1 + e2e: + specifier: workspace:* + version: link:../e2e typescript: specifier: ^6.0.2 version: 6.0.3 @@ -151,6 +157,22 @@ importers: specifier: 'catalog:' version: 0.1.22(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.0)(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.0)(yaml@2.8.3))(yaml@2.8.3) + packages/e2e: + dependencies: + bailian-cli-core: + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.12.2 + typescript: + specifier: ^6.0.2 + version: 6.0.3 + vite-plus: + specifier: 0.1.22 + version: 0.1.22(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.0)(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.0)(yaml@2.8.3))(yaml@2.8.3) + packages/kscli: dependencies: bailian-cli-commands: @@ -181,6 +203,9 @@ importers: chalk: specifier: 'catalog:' version: 5.6.2 + e2e: + specifier: workspace:* + version: link:../e2e typescript: specifier: ^6.0.2 version: 6.0.3 @@ -221,6 +246,9 @@ importers: ajv: specifier: 'catalog:' version: 8.20.0 + e2e: + specifier: workspace:* + version: link:../e2e typescript: specifier: ^6.0.2 version: 6.0.3 diff --git a/vite.config.ts b/vite.config.ts index e3173cd..36c4ad5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ test: { - globalSetup: "./packages/cli/tests/e2e/global-setup.ts", + globalSetup: "./packages/e2e/src/global-setup.ts", testTimeout: 60_000, hookTimeout: 60_000, },