diff --git a/AGENTS.md b/AGENTS.md index 46e7c3a52..cc60a1ec2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,26 +57,27 @@ Skill / 命令手册随 `skills/bailian-*/` 经 `bl skill init` 安装(装齐 按当前任务从下表挑一条进入对应文档: -| 场景 | 何时进入 | 详见 | -| ----------------- | ------------------------------------------------ | ---------------------------------------------------------------------------- | -| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) | -| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) | -| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) | -| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) | -| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) | -| Skill 文案 / 路由 | 改 SKILL 路由、安装约定、hand-off、hub/领域边界 | [docs/agents/skill-change.md](docs/agents/skill-change.md) | -| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) | -| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) | -| 埋点变更 | 改 AEM 命令事件、后端渠道 header、User-Agent | [docs/agents/telemetry-change.md](docs/agents/telemetry-change.md) | -| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) | -| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) | -| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) | -| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) | -| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) | -| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) | -| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) | -| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) | -| dsh 插件 | 改 `packages/bailian-kb-dsh`、dsh 依赖、插件发布 | [docs/agents/dsh-plugin.md](docs/agents/dsh-plugin.md) | +| 场景 | 何时进入 | 详见 | +| ----------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------- | +| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) | +| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) | +| 测试隔离 | 单测/E2E 受本地 HOME、语言、系统目录干扰;统一隔离基建 | [docs/agents/test-isolation.md](docs/agents/test-isolation.md) | +| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) | +| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) | +| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) | +| Skill 文案 / 路由 | 改 SKILL 路由、安装约定、hand-off、hub/领域边界 | [docs/agents/skill-change.md](docs/agents/skill-change.md) | +| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) | +| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) | +| 埋点变更 | 改 AEM 命令事件、后端渠道 header、User-Agent | [docs/agents/telemetry-change.md](docs/agents/telemetry-change.md) | +| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) | +| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) | +| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) | +| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) | +| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) | +| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) | +| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) | +| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) | +| dsh 插件 | 改 `packages/bailian-kb-dsh`、dsh 依赖、插件发布 | [docs/agents/dsh-plugin.md](docs/agents/dsh-plugin.md) | 如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/.md`,把清单沉淀下来。 diff --git a/docs/agents/cli-e2e-tests.md b/docs/agents/cli-e2e-tests.md index 8e2f301aa..2601f92a6 100644 --- a/docs/agents/cli-e2e-tests.md +++ b/docs/agents/cli-e2e-tests.md @@ -2,14 +2,14 @@ ## 架构分层 -| 层级 | 路径 | 测什么 | -| --------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------- | -| **共享基建** | `packages/e2e` | gating、子进程 runner、registry help 捕获、output、globalSetup(`private`,不发布) | -| **命令 E2E** | `packages/commands/tests/e2e` | 进程内 help、子进程缺参/dry-run/live(gated);每用例最小路由 | -| **Journey E2E** | `packages/commands/tests/e2e/knowledge/journeys` | 用户旅程全链路(跨命令回路 + 标记词召回闭环),全部 live gated;见 `journeys/README.md` | -| **bl smoke** | `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` | 产品 map 全部 path/分组的进程内 help;根 help、鉴权域等代表性子进程冒烟 | -| **kscli smoke** | `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` | map 全部 path/分组的进程内 help;`--version`、`search --help` 等代表性子进程冒烟 | -| **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 | +| 层级 | 路径 | 测什么 | +| --------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| **共享基建** | `packages/e2e` | gating、子进程 / 进程内 runner、registry help 捕获、output、globalSetup(`private`,不发布) | +| **命令 E2E** | `packages/commands/tests/e2e` | 进程内 help / 离线校验 / dry-run、代表性子进程、live(gated);每用例最小路由 | +| **Journey E2E** | `packages/commands/tests/e2e/knowledge/journeys` | 用户旅程全链路(跨命令回路 + 标记词召回闭环),全部 live gated;见 `journeys/README.md` | +| **bl smoke** | `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` | 产品 map 全部 path/分组的进程内 help;根 help、鉴权域等代表性子进程冒烟 | +| **kscli smoke** | `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` | map 全部 path/分组的进程内 help;`--version`、`search --help` 等代表性子进程冒烟 | +| **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 | **依赖边界**:`e2e` → `core`;`commands/tests` → `e2e` + `commands/src`;产品 tests → `e2e` + 各自 `src` + `runtime` 公共 API。**禁止**产品 import `commands/tests/**`(子进程 spawn harness 路径除外)。 @@ -30,7 +30,10 @@ - 路径:`packages/commands/tests/e2e/.e2e.test.ts`;knowledge 领域集中在 `packages/commands/tests/e2e/knowledge/` 子目录(新增 knowledge 命令测试放这里) - help:`runCommandHelp(routes, [...path, "--help"])` from `./helpers.ts`(当前 Vitest worker 内用真实 command + `CommandRegistry` 渲染,不启动子进程) -- 子进程:缺参、dry-run、live 使用 `runCommandE2e(routes, args)`(spawn `harness/main.ts`,`routes` 为本 topic 最小 path → export 映射) +- 离线校验 / dry-run:确认用例不依赖进程初始化、真实请求或持久化后,可显式使用 `runCommandInProcess(routes, args, envOverrides)`;走真实 `createCli`、middleware 和 command,捕获 stdout / stderr / 退出码,执行后恢复进程环境。worker 内串行捕获,禁止与其他操作全局状态的并发用例混用 +- 子进程:`runCommandE2e(routes, args)` 保留给 live、实际写盘、进程隔离及 shell / stdio / env 契约;每个 topic 保留代表性子进程用例。`NODE_OPTIONS` / HTTP(S) 代理配置存在时,`runCommandInProcess` 自动回退子进程 +- 路由加载:harness 按 command 库公开导出名加载 topic 所需模块,不整体求值命令 barrel;新增导出形式需扩展 loader 并保留与公开导出对象一致性的测试 +- 更新检查:未发布的 harness 包 `latest` 查询固定返回本地 404,避免重复访问 npm;只拦截这个精确 URL,业务 fetch 和生产 updater 测试仍走原逻辑 - fixtures:`packages/commands/tests/e2e/fixtures/` - 路由常量:`topic-routes.ts`(按 topic 维护,**非**全量产品 map) @@ -44,7 +47,7 @@ ### 共享 -- gating / output / runner:`e2e/gating`、`e2e/output`、`e2e/runner`;runner 使用 `node --import tsx` 执行 TypeScript 入口,不启动 tsx CLI IPC server +- gating / output / runner:`e2e/gating`、`e2e/output`、`e2e/runner`、`e2e/in-process`;runner 使用 `node --import tsx` 执行 TypeScript 入口,不启动 tsx CLI IPC server - globalSetup:根 `vite.config.ts` → `packages/e2e/src/global-setup.ts` - 解析 JSON stdout:`parseStdoutJson`;输出目录:`makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url))` - 长任务:`cliTimeoutPrefix()`;视频用例加 `test(..., 3_600_000)` 等显式超时 @@ -72,7 +75,7 @@ describe.skipIf()("e2e: (DashScope …)", () => { | 场景 | 条件 | | ----------------------- | ---------------------------------------------------------------------------------------------------------- | -| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` | +| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()`(`.env` 中的 `DASHSCOPE_API_KEY`) | | 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` | | 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` | | OpenAPI AK/SK | `isOpenApiE2EReady()`(`.env` 中必须同时提供完整 AK/SK) | @@ -110,6 +113,7 @@ describe.skipIf()("e2e: (DashScope …)", () => { - **禁止破坏真实用户配置**:`auth logout` 和 `config set` 默认只用 `--dry-run`;只有验证持久化契约时,才允许通过 `BAILIAN_CONFIG_DIR` 指向每个用例独占的临时目录实际落盘,并必须在 `finally` 中清理;禁止写入或复用真实 `~/.bailian` +- **默认测试隔离**:`runNodeMain` / `runCommandE2e` / `runCommandInProcess` / `runCli` / `runKscli` 默认走 `e2e/isolation`(HOME 与 `XDG_CONFIG_HOME` 隔离、强制 `language: en-US`、关闭 HOME 外的系统/项目目录探测、清空 Agent 覆盖 env)。`runCommandInProcess` 经 `buildIsolatedCliEnv` 后把完整 env 快照交给 `runInProcess`(与子进程 `env` 同语义)。DashScope / OpenAPI 凭证继续走环境变量;Console live 用例用 `runConsoleCommandE2e` / `runConsoleCli` 带上 `access_token`。进程内单测用 `withIsolatedHome` / `enterIsolatedHome`,**禁止**各文件自写 `inFakeHome`。完整约定见 [test-isolation.md](test-isolation.md)。 - **不加 dry-run**:`dryRun` 在 `resolveFileUrl` / `resolveCredential` / 上传**之后**的命令(如 `image edit`、`speech recognize` 带 `--url`) - **`--list-voices` 等旁路**:先于 `--text` 校验的 flag,缺参用例勿带该 flag - 新增 required option → 至少一条缺参用例;改 dry-run 输出 → 更新对应断言 diff --git a/docs/agents/test-isolation.md b/docs/agents/test-isolation.md new file mode 100644 index 000000000..c444211ef --- /dev/null +++ b/docs/agents/test-isolation.md @@ -0,0 +1,86 @@ +# 测试隔离 + +## 触发条件 + +- 新增/修改会读 `~/.bailian`、`$HOME`、Agent 配置目录或 help 文案语言的单测 / E2E +- 修「本机绿、CI 红」或反过来、且怀疑是本地配置/语言/系统目录泄漏 +- 调整 `packages/e2e` runner、skills/agent 探测、`BAILIAN_CONFIG_DIR` 相关测试基建 + +## 概念图 + +``` +宿主机器 + ~/.bailian (language/output/凭证) + /etc/codex、/Applications/…(系统级 Agent 探测) + $CODEX_HOME 等覆盖变量 + │ + ▼ + e2e/isolation(统一入口) + ├─ 进程内单测:withIsolatedHome / enterIsolatedHome + ├─ 离线 in-process E2E:runCommandInProcess → buildIsolatedCliEnv → runInProcess(完整 env 快照) + └─ 子进程 E2E:runNodeMain → buildIsolatedCliEnv + │ + ├─ 调用方 HOME,否则临时 HOME;XDG 始终改写到该 HOME + ├─ 未传 BAILIAN_CONFIG_DIR:空 HOME 写 $HOME/.bailian;已有 config 则用自有临时目录(不覆盖) + ├─ overrides 的 undefined / 隔离关键键空串忽略,不能拆掉隔离 + ├─ DashScope / OpenAPI 凭证继续走环境变量 + ├─ Console access_token 仅 seedCredentials: true 时拷贝 + ├─ 清空 AGENT_ENV_KEYS(含 OPENCLAW / ZCODE / LOCALAPPDATA) + ├─ BL_SKIP_HOST_AGENTS=1 + └─ 结束后 dispose / restore 删除自己创建的临时目录 + + Session 父目录(仅扫尾归堆,不承载隔离语义): + globalSetup createIsolationSessionRoot → provide → setupFiles 写 BL_ISO_ROOT + → 临时子目录挂其下;无 BL_ISO_ROOT 时落 os.tmpdir() + → teardown 只删闭包里的 root(无进程 ownership / 信号清理) +``` + +## 必查清单 + +### A. 选用哪一层隔离 + +- [ ] **core 进程内单测**(skills / advisor):`import { withIsolatedHome } from "./helpers/isolation.ts"`(core 不依赖 e2e,避免环) +- [ ] **commands / runtime 进程内单测**(agent writer 等):`import { withIsolatedHome, enterIsolatedHome } from "e2e/isolation"` +- [ ] 禁止本地再写 `inFakeHome` / 直接 `process.env.HOME =` +- [ ] **CLI 子进程 E2E**:走 `runCommandE2e` / `runCli` / `runKscli` → 底层 `runNodeMain` **默认已隔离**;用例只需在需要时传自己的 `BAILIAN_CONFIG_DIR` / `HOME` +- [ ] **离线 in-process E2E**:走 `runCommandInProcess` → 同样 `buildIsolatedCliEnv`;`runInProcess` 的 `env` 是完整快照(非增量 merge) +- [ ] 只传 `HOME` 且该 HOME 下**已有** `config.json` 时,基建**不会覆盖**,改用自有临时 `BAILIAN_CONFIG_DIR`;空 HOME 才写 `$HOME/.bailian` +- [ ] 隔离关键键的 `undefined` / 空串会被忽略,不能拆掉隔离;其它键传 `undefined` 会删除继承凭证(如 `DASHSCOPE_API_KEY: undefined`) +- [ ] **必须继承真实用户配置**的极少数用例:`runNodeMain(..., { isolate: false })`,并在注释里写明原因 + +### B. 系统目录与 Agent 探测 + +- [ ] 依赖 `detectInstalledAgents` 的断言必须在隔离环境下跑(`BL_SKIP_HOST_AGENTS=1`) +- [ ] 新增落在 HOME / `XDG_CONFIG_HOME` 之外的绝对 detectDirs(含 `/etc`、`/Applications` 和 cwd 项目目录)时,确认 `isHostSystemDetectDir` 仍判为主机路径;并补一条「隔离开关打开时不被探测」的测试。不要把判断收窄成只认 `/etc` 和 `/Applications` +- [ ] 新增官方路径覆盖变量时,同步加入 `AGENT_ENV_KEYS`(含 MCP 的 `OPENCLAW_CONFIG_PATH` / `ZCODE_HOME`) +- [ ] inventory / mcp-agent-config 等「显式收 home、但仍读 process.env」的单测,用 `enterIsolatedHome` 或清空 `AGENT_ENV_KEYS` + +### C. 语言 / 输出格式断言 + +- [ ] help / 文案断言默认按 **en-US** 写(隔离 config 已强制);若测中文,显式构造带 `language: "zh-CN"` 的临时 config +- [ ] 解析 stdout 的 dry-run 用例:要么显式 `--output json`,要么依赖命令默认 JSON,**不要**依赖用户 `config.output` +- [ ] 禁止用本机 `~/.bailian` 的 language/output 作为隐式测试输入 + +### D. 文档 + +- [ ] 改隔离约定时同步更新本文与 [cli-e2e-tests.md](cli-e2e-tests.md) 的安全/隔离段落 +- [ ] 复现本机污染:保留 `/etc/codex` 或 `language: zh-CN` 配置,确认相关用例仍绿 + +## 完成后自查 + +```sh +pnpm --filter e2e exec vp test tests/isolation.test.ts +pnpm --filter bailian-cli-core exec vp test tests/skills-agents.test.ts tests/advisor-sync.test.ts +pnpm --filter bailian-cli-commands exec vp test tests/e2e/managed-agent.e2e.test.ts tests/e2e/permission.e2e.test.ts +``` + +## 常见漏点 + +- 只改 `HOME`、忘了清 `CODEX_HOME` / `CLAUDE_CONFIG_DIR` / `OPENCLAW_CONFIG_PATH` / `ZCODE_HOME` / `XDG_CONFIG_HOME` / `LOCALAPPDATA` → 探测或写入仍指向开发机 +- 只清 env、忘了系统路径 `/etc/codex` 或 cwd 下的项目目录 → 仍探测到本机 Agent +- 对调用方已有 `HOME/.bailian/config.json` 无条件 `writeFileSync` → 冲掉用户配置;应改用基建自有临时 config 目录 +- 隔离关键键 `HOME: undefined` → 须忽略,否则 spawn 丢掉键后回退真实 home +- 非关键键 `DASHSCOPE_API_KEY: undefined` → 须删除该键;若 sanitize 一律丢弃 undefined,则「无需凭证」用例会带着父进程 Key 假绿 +- E2E 子进程继承 `process.env` + 真实 `~/.bailian` → help 变成中文、output 偏好干扰 JSON 解析 +- 各包各自写 `inFakeHome`,清单不一致 → 用 `e2e/isolation` 统一 +- live console e2e 需要 `access_token`:调用 `runConsoleCommandE2e` / `runConsoleCli`,或给 `runNodeMain` 传 `seedCredentials: true`。默认不拷贝 `api_key` 和 AK/SK;不要为了带上 token 使用 `isolate: false` diff --git a/packages/cli/tests/e2e/helpers.ts b/packages/cli/tests/e2e/helpers.ts index 8d42da71c..db3a29a1b 100644 --- a/packages/cli/tests/e2e/helpers.ts +++ b/packages/cli/tests/e2e/helpers.ts @@ -47,6 +47,19 @@ const mainTs = join(cliPackageRoot, "src", "main.ts"); export async function runCli( args: string[], envOverrides: NodeJS.ProcessEnv = {}, + options: { seedCredentials?: boolean } = {}, ): Promise { - return runNodeMain(mainTs, args, { cwd: cliPackageRoot, env: envOverrides }); + return runNodeMain(mainTs, args, { + cwd: cliPackageRoot, + env: envOverrides, + seedCredentials: options.seedCredentials, + }); +} + +/** Console live 用例:隔离环境里带上本机 access_token。 */ +export async function runConsoleCli( + args: string[], + envOverrides: NodeJS.ProcessEnv = {}, +): Promise { + return runCli(args, envOverrides, { seedCredentials: true }); } diff --git a/packages/cli/tests/e2e/usage-summary.e2e.test.ts b/packages/cli/tests/e2e/usage-summary.e2e.test.ts index 448d9bd93..515759fdd 100644 --- a/packages/cli/tests/e2e/usage-summary.e2e.test.ts +++ b/packages/cli/tests/e2e/usage-summary.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, + runCli, + runConsoleCli, +} from "./helpers.ts"; describe("e2e: usage summary", () => { test("usage summary --help 正常退出", async () => { @@ -18,7 +24,7 @@ describe("e2e: usage summary", () => { describe.skipIf(!isConsoleE2EReady())("e2e: usage summary(Console)", () => { test("usage summary --dry-run 输出 free-tier 计划请求", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runConsoleCli([ "usage", "summary", "--dry-run", @@ -31,7 +37,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage summary(Console)", () => { }); test("usage summary --dry-run --workspace-id 附带用量概览计划请求", async () => { - const { stdout, stderr, exitCode } = await runCli([ + const { stdout, stderr, exitCode } = await runConsoleCli([ "usage", "summary", "--dry-run", @@ -49,13 +55,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage summary(Console)", () => { }); test("usage summary 文本输出正常返回", async () => { - const result = await runCli(["usage", "summary", "--output", "text"]); + const result = await runConsoleCli(["usage", "summary", "--output", "text"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); }); test("usage summary JSON 输出包含 freeTier 字段", async () => { - const result = await runCli(["usage", "summary", "--output", "json"]); + const result = await runConsoleCli(["usage", "summary", "--output", "json"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); const data = parseStdoutJson<{ freeTier?: unknown; period?: unknown }>(result.stdout); diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index 5e967ec22..963cab009 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ test: { globalSetup: "../e2e/src/global-setup.ts", + setupFiles: ["../e2e/src/iso-root-setup.ts"], testTimeout: 60_000, hookTimeout: 60_000, }, diff --git a/packages/commands/tests/config-agent-writers.test.ts b/packages/commands/tests/config-agent-writers.test.ts index 52eae9eef..45dcba21a 100644 --- a/packages/commands/tests/config-agent-writers.test.ts +++ b/packages/commands/tests/config-agent-writers.test.ts @@ -1,14 +1,7 @@ -import { - mkdtempSync, - rmSync, - readFileSync, - writeFileSync, - mkdirSync, - readdirSync, - existsSync, -} from "fs"; -import { tmpdir, homedir } from "os"; +import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from "fs"; +import { homedir } from "os"; import { join } from "path"; +import { enterIsolatedHome, type IsolatedHomeHandle } from "e2e/isolation"; import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test"; import claudeCode from "../src/commands/config/agent/writers/claude-code.ts"; import qwenCode from "../src/commands/config/agent/writers/qwen-code.ts"; @@ -28,20 +21,18 @@ const OAI_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"; const ANTHROPIC_URL = "https://dashscope.aliyuncs.com/apps/anthropic"; let home = ""; -let prevHome: string | undefined; +let isolation: IsolatedHomeHandle | undefined; beforeEach(() => { - home = mkdtempSync(join(tmpdir(), "bl-agent-writer-")); - prevHome = process.env.HOME; - process.env.HOME = home; + isolation = enterIsolatedHome(); + home = isolation.home; // homedir() 在 POSIX 读 $HOME;断言隔离生效。 expect(homedir()).toBe(home); }); afterEach(() => { - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - rmSync(home, { recursive: true, force: true }); + isolation?.restore(); + isolation = undefined; }); function readJsonAt(...segments: string[]): Record { diff --git a/packages/commands/tests/e2e/alert.e2e.test.ts b/packages/commands/tests/e2e/alert.e2e.test.ts index ccc3ee9d6..8f382c7cd 100644 --- a/packages/commands/tests/e2e/alert.e2e.test.ts +++ b/packages/commands/tests/e2e/alert.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { parseStdoutJson, runCommandE2e, runCommandInProcess } from "./helpers.ts"; import { ALERT_ROUTES } from "./topic-routes.ts"; // 只覆盖 help / 参数校验 / dry-run;真实调用依赖 console 凭证与已开通的 CMS 服务。 @@ -74,7 +74,7 @@ describe("e2e: alert", () => { }); test("alert create --dry-run 输出完整通知配置", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(ALERT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(ALERT_ROUTES, [ "alert", "create", "--name", @@ -146,7 +146,7 @@ describe("e2e: alert", () => { }); test("alert delete --dry-run 支持多 ID", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(ALERT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(ALERT_ROUTES, [ "alert", "delete", "--rule-id", @@ -187,7 +187,7 @@ describe("e2e: alert", () => { ["enable", "zeldaEasy.bailian-telemetry.alertRule.enableAlertRule"], ["disable", "zeldaEasy.bailian-telemetry.alertRule.disableAlertRule"], ] as const) { - const { stdout, stderr, exitCode } = await runCommandE2e(ALERT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(ALERT_ROUTES, [ "alert", verb, "--rule-id", @@ -246,7 +246,7 @@ describe("e2e: alert", () => { }); test("alert template create --dry-run 输出条件结构", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(ALERT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(ALERT_ROUTES, [ "alert", "template", "create", @@ -295,7 +295,7 @@ describe("e2e: alert", () => { }); test("alert template update --dry-run 输出更新请求", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(ALERT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(ALERT_ROUTES, [ "alert", "template", "update", @@ -319,7 +319,7 @@ describe("e2e: alert", () => { }); test("alert template delete --dry-run 支持多 ID", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(ALERT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(ALERT_ROUTES, [ "alert", "template", "delete", @@ -362,7 +362,7 @@ describe("e2e: alert", () => { }); test("alert history --dry-run 输出时间范围映射", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(ALERT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(ALERT_ROUTES, [ "alert", "history", "--rule-id", diff --git a/packages/commands/tests/e2e/command-in-process.test.ts b/packages/commands/tests/e2e/command-in-process.test.ts new file mode 100644 index 000000000..a54ff58af --- /dev/null +++ b/packages/commands/tests/e2e/command-in-process.test.ts @@ -0,0 +1,125 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vite-plus/test"; +import { runCommandE2e, runCommandInProcess } from "./helpers.ts"; +import { + CONFIG_ROUTES, + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + QUOTA_ROUTES, +} from "./topic-routes.ts"; + +const captureEnvironment = { + NODE_OPTIONS: undefined, + HTTP_PROXY: undefined, + HTTPS_PROXY: undefined, + http_proxy: undefined, + https_proxy: undefined, +}; + +const cases = [ + { name: "group help", routes: QUOTA_ROUTES, args: ["quota", "--help"] }, + { + name: "supplied flag usage error", + routes: QUOTA_ROUTES, + args: ["quota", "list", "--unsupported", "--output", "json"], + }, + { + name: "required flag usage error", + routes: CONFIG_ROUTES, + args: ["config", "set", "--quiet", "--output", "json"], + }, + { + name: "config dry-run", + routes: CONFIG_ROUTES, + args: [ + "config", + "set", + "--key", + "watermark", + "--value", + "false", + "--dry-run", + "--output", + "json", + ], + }, + { + name: "command validation", + routes: KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + args: [ + "knowledge", + "chunk", + "add", + "--index-id", + "fixture", + "--content", + "hello", + "--field", + "column=1", + "--workspace-id", + "fixture", + "--dry-run", + "--output", + "json", + ], + }, +]; + +test.each(cases)( + "in-process $name matches the real CLI subprocess exactly", + async ({ routes, args }) => { + const directory = mkdtempSync(join(tmpdir(), "bl-command-parity-")); + try { + const environment = { + ...captureEnvironment, + BAILIAN_CONFIG_DIR: directory, + DASHSCOPE_API_KEY: "", + BAILIAN_E2E: "0", + }; + const subprocess = await runCommandE2e(routes, args, environment); + const inProcess = await runCommandInProcess(routes, args, environment); + expect(inProcess).toEqual(subprocess); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, +); + +test("profile-language errors match in-process and subprocess output", async () => { + const directory = mkdtempSync(join(tmpdir(), "bl-command-locale-parity-")); + try { + writeFileSync( + join(directory, "config.json"), + JSON.stringify({ language: "en-US", work: { language: "zh-CN" } }), + ); + const args = ["--config", "work", "quota", "list", "--unsupported", "--output", "json"]; + const environment = { ...captureEnvironment, BAILIAN_CONFIG_DIR: directory }; + const result = await runCommandInProcess(QUOTA_ROUTES, args, environment); + expect(result).toEqual(await runCommandE2e(QUOTA_ROUTES, args, environment)); + expect(result.stderr).toContain("未知参数"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("Node preloads retain the real subprocess contract", async () => { + const directory = mkdtempSync(join(tmpdir(), "bl-command-preload-")); + // esbuild workers also inherit NODE_OPTIONS on cache misses; only the CLI main thread marks startup. + const preload = + 'import { isMainThread } from "node:worker_threads"; if (isMainThread) process.stdout.write("preload marker\\n");'; + try { + for (const disableCache of [undefined, "1"]) { + const result = await runCommandInProcess(QUOTA_ROUTES, ["quota", "--help"], { + BAILIAN_CONFIG_DIR: directory, + TSX_DISABLE_CACHE: disableCache, + NODE_OPTIONS: `--import=data:text/javascript,${encodeURIComponent(preload)}`, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("preload marker\n"); + expect(result.stderr).toContain("Usage: bl quota"); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/commands/tests/e2e/command-routes.test.ts b/packages/commands/tests/e2e/command-routes.test.ts new file mode 100644 index 000000000..fb6d2ccee --- /dev/null +++ b/packages/commands/tests/e2e/command-routes.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "vite-plus/test"; +import { loadRouteCommands } from "./harness/routes.ts"; +import * as topicRoutes from "./topic-routes.ts"; +import * as commandExports from "../../src/index.ts"; + +const fixtureIndex = new URL("./fixtures/route-loader/index.ts", import.meta.url); + +test("loads a declared command without evaluating an unrelated broken export", async () => { + const commands = await loadRouteCommands({ "probe run": "probe" }, fixtureIndex); + expect(commands["probe run"].description).toBe("fixture probe"); + expect(await commands["probe run"].run({} as never)).toBe("probe result"); +}); + +test("aliases retain the same command instance", async () => { + const commands = await loadRouteCommands( + { "probe run": "probe", "probe alias": "probeAlias" }, + fixtureIndex, + ); + expect(commands["probe run"]).toBe(commands["probe alias"]); +}); + +test("rejects unknown exports and declared non-command defaults", async () => { + await expect(loadRouteCommands({ invalid: "missing" }, fixtureIndex)).rejects.toThrow( + "Unknown bailian-cli-commands export: missing", + ); + await expect(loadRouteCommands({ invalid: "notACommand" }, fixtureIndex)).rejects.toThrow( + "Unknown bailian-cli-commands export: notACommand", + ); +}); + +test("every topic route resolves to the same public command exported by the library", async () => { + const library = commandExports as Record; + for (const routes of Object.values(topicRoutes)) { + const commands = await loadRouteCommands(routes); + for (const [path, exportName] of Object.entries(routes)) + expect(commands[path]).toBe(library[exportName]); + } +}); + +test("supports named aliases and preserves route declaration order", async () => { + const routes = { "named first": "namedProbe", "default second": "probe" }; + const commands = await loadRouteCommands(routes, fixtureIndex); + expect(Object.keys(commands)).toEqual(Object.keys(routes)); + expect(await commands["named first"].run({} as never)).toBe("named result"); +}); diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index d38e15fb5..0c7314056 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs import { tmpdir } from "os"; import { join } from "path"; import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts"; +import { parseStdoutJson, runCommandHelp, runCommandE2e, runCommandInProcess } from "./helpers.ts"; import { CONFIG_ROUTES } from "./topic-routes.ts"; /** @@ -39,7 +39,7 @@ describe("e2e: config", () => { }); test("config ui --dry-run 打印计划不起服务", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(CONFIG_ROUTES, [ "config", "ui", "--dry-run", @@ -199,7 +199,7 @@ describe("e2e: config", () => { try { const configPath = join(configDir, "config.json"); writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n"); - const result = await runCommandE2e( + const result = await runCommandInProcess( CONFIG_ROUTES, ["config", "use", "--name", "dev", "--dry-run", "--output", "json"], { BAILIAN_CONFIG_DIR: configDir }, @@ -386,7 +386,7 @@ describe("e2e: config", () => { }); test("config set --dry-run 不落盘(仅输出 would_set)", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(CONFIG_ROUTES, [ "config", "set", "--dry-run", @@ -403,7 +403,7 @@ describe("e2e: config", () => { }); test("config set --dry-run 支持连字符别名 key", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(CONFIG_ROUTES, [ "config", "set", "--dry-run", @@ -422,7 +422,7 @@ describe("e2e: config", () => { }); test("config set --dry-run 支持图生视频默认模型别名", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(CONFIG_ROUTES, [ "config", "set", "--dry-run", @@ -441,7 +441,7 @@ describe("e2e: config", () => { }); test("config set --dry-run 支持参考生视频默认模型别名", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(CONFIG_ROUTES, [ "config", "set", "--dry-run", @@ -460,7 +460,7 @@ describe("e2e: config", () => { }); test("config set --dry-run 展示归一化后的 Base URL", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(CONFIG_ROUTES, [ "config", "set", "--dry-run", @@ -477,7 +477,7 @@ describe("e2e: config", () => { }); test("config set --dry-run 支持 AccessKey 短字段别名", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(CONFIG_ROUTES, [ "config", "set", "--dry-run", @@ -547,7 +547,7 @@ describe("e2e: config", () => { }); test("config agent --key 非法值时报用法错误 (2)", async () => { - const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(CONFIG_ROUTES, [ "config", "agent", "--agent", @@ -567,7 +567,7 @@ describe("e2e: config", () => { test("config agent --key 合法值 --dry-run 解码成功且输出脱敏", async () => { const home = mkdtempSync(join(tmpdir(), "bl-config-agent-key-")); try { - const { stdout, stderr, exitCode } = await runCommandE2e( + const { stdout, stderr, exitCode } = await runCommandInProcess( CONFIG_ROUTES, [ "config", @@ -602,7 +602,7 @@ describe("e2e: config", () => { test("config agent --region 转为 base URL,--dry-run 成功", async () => { const home = mkdtempSync(join(tmpdir(), "bl-config-agent-region-")); try { - const { stdout, stderr, exitCode } = await runCommandE2e( + const { stdout, stderr, exitCode } = await runCommandInProcess( CONFIG_ROUTES, [ "config", @@ -686,7 +686,7 @@ describe("e2e: config", () => { test("config agent --dry-run 输出脱敏信息且不写盘", async () => { const home = mkdtempSync(join(tmpdir(), "bl-config-agent-dry-")); try { - const { stdout, stderr, exitCode } = await runCommandE2e( + const { stdout, stderr, exitCode } = await runCommandInProcess( CONFIG_ROUTES, [ "config", diff --git a/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts b/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts index d817b7944..ad9661f30 100644 --- a/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts +++ b/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts"; +import { parseStdoutJson, runCommandHelp, runCommandE2e, runCommandInProcess } from "./helpers.ts"; import { CONSOLE_FLAGS_DRY_RUN_ROUTES } from "./topic-routes.ts"; type ConsoleDryRunMeta = { @@ -56,7 +56,7 @@ describe("e2e: console global flags (dry-run)", () => { }); test("console call --dry-run --console-region / --console-site / --console-switch-agent 透传", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "console", "call", "--api", @@ -81,7 +81,7 @@ describe("e2e: console global flags (dry-run)", () => { }); test("console call 拒绝未知全局 flag --region", async () => { - const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "console", "call", "--api", @@ -97,7 +97,7 @@ describe("e2e: console global flags (dry-run)", () => { }); test("mcp list --dry-run --console-region 透传", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "mcp", "list", "--dry-run", @@ -112,7 +112,7 @@ describe("e2e: console global flags (dry-run)", () => { }); test("quota check --dry-run --console-region 透传", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "quota", "check", "--dry-run", diff --git a/packages/commands/tests/e2e/deploy.e2e.test.ts b/packages/commands/tests/e2e/deploy.e2e.test.ts index bd1ba54b8..6a76a92c3 100644 --- a/packages/commands/tests/e2e/deploy.e2e.test.ts +++ b/packages/commands/tests/e2e/deploy.e2e.test.ts @@ -1,6 +1,12 @@ import { fileURLToPath } from "node:url"; import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts"; +import { + isDashScopeE2EReady, + parseStdoutJson, + runCommandHelp, + runCommandE2e, + runCommandInProcess, +} from "./helpers.ts"; import { DEPLOY_ROUTES } from "./topic-routes.ts"; // No files are created: isolate user config and block fetch in the child process, @@ -15,7 +21,7 @@ const READ_ONLY_ENV: NodeJS.ProcessEnv = { }; function runReadOnlyDeploy(args: string[]) { - return runCommandE2e( + return runCommandInProcess( DEPLOY_ROUTES, ["deploy", ...args, "--api-key", "e2e-dummy-key", "--dry-run", "--output", "json", "--quiet"], READ_ONLY_ENV, diff --git a/packages/commands/tests/e2e/fixtures/route-loader/broken.ts b/packages/commands/tests/e2e/fixtures/route-loader/broken.ts new file mode 100644 index 000000000..7eb517381 --- /dev/null +++ b/packages/commands/tests/e2e/fixtures/route-loader/broken.ts @@ -0,0 +1,2 @@ +export default {}; +throw new Error("Unrelated command must not be evaluated"); diff --git a/packages/commands/tests/e2e/fixtures/route-loader/index.ts b/packages/commands/tests/e2e/fixtures/route-loader/index.ts new file mode 100644 index 000000000..dc3b7cc68 --- /dev/null +++ b/packages/commands/tests/e2e/fixtures/route-loader/index.ts @@ -0,0 +1,5 @@ +export { default as probe } from "./probe.ts"; +export { default as probeAlias } from "./probe.ts"; +export { default as broken } from "./broken.ts"; +export { default as notACommand } from "./not-a-command.ts"; +export { probe as namedProbe } from "./named-probe.ts"; diff --git a/packages/commands/tests/e2e/fixtures/route-loader/named-probe.ts b/packages/commands/tests/e2e/fixtures/route-loader/named-probe.ts new file mode 100644 index 000000000..d666cb004 --- /dev/null +++ b/packages/commands/tests/e2e/fixtures/route-loader/named-probe.ts @@ -0,0 +1 @@ +export const probe = { description: "named probe", auth: "none", run: async () => "named result" }; diff --git a/packages/commands/tests/e2e/fixtures/route-loader/not-a-command.ts b/packages/commands/tests/e2e/fixtures/route-loader/not-a-command.ts new file mode 100644 index 000000000..415c4a11a --- /dev/null +++ b/packages/commands/tests/e2e/fixtures/route-loader/not-a-command.ts @@ -0,0 +1 @@ +export default { description: "not a command" }; diff --git a/packages/commands/tests/e2e/fixtures/route-loader/probe.ts b/packages/commands/tests/e2e/fixtures/route-loader/probe.ts new file mode 100644 index 000000000..48bd60afb --- /dev/null +++ b/packages/commands/tests/e2e/fixtures/route-loader/probe.ts @@ -0,0 +1 @@ +export default { description: "fixture probe", auth: "none", run: async () => "probe result" }; diff --git a/packages/commands/tests/e2e/harness-update.test.ts b/packages/commands/tests/e2e/harness-update.test.ts new file mode 100644 index 000000000..f7c50a833 --- /dev/null +++ b/packages/commands/tests/e2e/harness-update.test.ts @@ -0,0 +1,40 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { expect, test } from "vite-plus/test"; +import { fetchLatestVersion, NPM_REGISTRY } from "bailian-cli-runtime"; +import { E2E_HARNESS_IDENTITY } from "./harness/identity.ts"; +import { withHarnessUpdateFixture } from "./harness/update-fixture.ts"; + +test("the harness receives a local 404 for its unpublished package and delegates business fetches", async () => { + const originalFetch = globalThis.fetch; + const server = http.createServer((_request, response) => { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ business: "unchanged" })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + try { + await withHarnessUpdateFixture(async () => { + expect(await fetchLatestVersion(100, E2E_HARNESS_IDENTITY.npmPackage)).toBeNull(); + const response = await fetch(`${NPM_REGISTRY}/${E2E_HARNESS_IDENTITY.npmPackage}/latest`); + expect(response.status).toBe(404); + expect(await (await fetch(`http://127.0.0.1:${address.port}`)).json()).toEqual({ + business: "unchanged", + }); + }); + expect(globalThis.fetch).toBe(originalFetch); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test("restores fetch after an unexpected command failure", async () => { + const originalFetch = globalThis.fetch; + await expect( + withHarnessUpdateFixture(async () => { + throw new Error("fixture failure"); + }), + ).rejects.toThrow("fixture failure"); + expect(globalThis.fetch).toBe(originalFetch); +}); diff --git a/packages/commands/tests/e2e/harness/identity.ts b/packages/commands/tests/e2e/harness/identity.ts index ff6d5f1bb..292a183a9 100644 --- a/packages/commands/tests/e2e/harness/identity.ts +++ b/packages/commands/tests/e2e/harness/identity.ts @@ -2,8 +2,8 @@ import pkg from "../../../package.json" with { type: "json" }; /** * commands E2E harness 产品身份。 - * version 取自 commands 包(与 monorepo 同步);npmPackage 使用非发布名以阻断 - * versionCheckStage 的 registry 查询与 CI 中的 auto-update。 + * version 取自 commands 包(与 monorepo 同步);npmPackage 使用非发布名, + * 配合 update-fixture 的本地 404 隔离更新查询与 CI 中的 auto-update。 */ export const E2E_HARNESS_IDENTITY = { binName: "bl", diff --git a/packages/commands/tests/e2e/harness/main.ts b/packages/commands/tests/e2e/harness/main.ts index a6c65d650..67d885ae8 100644 --- a/packages/commands/tests/e2e/harness/main.ts +++ b/packages/commands/tests/e2e/harness/main.ts @@ -1,29 +1,20 @@ -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"; +import { loadRouteCommands } from "./routes.ts"; +import { withHarnessUpdateFixture } from "./update-fixture.ts"; interface RouteSpec { path: string; export: string; } -function buildRoutesFromEnv(): Record { +async function main(): Promise { const raw = process.env.BAILIAN_E2E_ROUTES; - if (!raw?.trim()) { - throw new Error("BAILIAN_E2E_ROUTES is required for commands E2E harness"); - } + 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; + const routes = Object.fromEntries(spec.map(({ path, export: exportName }) => [path, exportName])); + const commands = await loadRouteCommands(routes); + await withHarnessUpdateFixture(() => createCli(commands, E2E_HARNESS_IDENTITY).run()); } -void createCli(buildRoutesFromEnv(), E2E_HARNESS_IDENTITY).run(); +void main(); diff --git a/packages/commands/tests/e2e/harness/routes.ts b/packages/commands/tests/e2e/harness/routes.ts new file mode 100644 index 000000000..563141463 --- /dev/null +++ b/packages/commands/tests/e2e/harness/routes.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "node:fs"; +import type { AnyCommand } from "bailian-cli-core"; +import type { E2eRouteExports } from "../topic-routes.ts"; + +const libraryIndex = new URL("../../../src/index.ts", import.meta.url); +interface ExportTarget { + moduleUrl: URL; + importedName: string; +} +const exportMaps = new Map>(); + +function declaredExports(index: URL): Map { + const cached = exportMaps.get(index.href); + if (cached) return cached; + const source = readFileSync(index, "utf8"); + const exports = new Map(); + const declaration = /export\s*\{([^}]+)\}\s*from\s*(["'])([^"']+)\2/g; + for (const match of source.matchAll(declaration)) { + for (const entry of match[1]!.split(",")) { + const alias = /^\s*([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(entry); + if (alias) + exports.set(alias[2] ?? alias[1]!, { + importedName: alias[1]!, + moduleUrl: new URL(match[3]!, index), + }); + } + } + exportMaps.set(index.href, exports); + return exports; +} + +/** Resolve public export names without evaluating the complete command barrel. */ +export async function loadRouteCommands( + routes: E2eRouteExports, + index: URL = libraryIndex, +): Promise> { + const exports = declaredExports(index); + const entries = await Promise.all( + Object.entries(routes).map(async ([path, exportName]) => { + const target = exports.get(exportName); + if (!target) throw new Error(`Unknown bailian-cli-commands export: ${exportName}`); + const command: unknown = (await import(target.moduleUrl.href))[target.importedName]; + if (typeof command !== "object" || command === null || !("run" in command)) + throw new Error(`Unknown bailian-cli-commands export: ${exportName}`); + return [path, command as AnyCommand] as const; + }), + ); + return Object.fromEntries(entries); +} diff --git a/packages/commands/tests/e2e/harness/update-fixture.ts b/packages/commands/tests/e2e/harness/update-fixture.ts new file mode 100644 index 000000000..d3531d25c --- /dev/null +++ b/packages/commands/tests/e2e/harness/update-fixture.ts @@ -0,0 +1,20 @@ +import { NPM_REGISTRY } from "bailian-cli-runtime"; +import { E2E_HARNESS_IDENTITY } from "./identity.ts"; + +/** The unpublished harness package is always a local 404; business requests retain real fetch. */ +export async function withHarnessUpdateFixture( + run: () => Promise, +): Promise { + const originalFetch = globalThis.fetch; + const updateUrl = `${NPM_REGISTRY}/${E2E_HARNESS_IDENTITY.npmPackage}/latest`; + globalThis.fetch = (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url === updateUrl) return Promise.resolve(new Response(null, { status: 404 })); + return originalFetch(input, init); + }; + try { + return await run(); + } finally { + globalThis.fetch = originalFetch; + } +} diff --git a/packages/commands/tests/e2e/helpers.ts b/packages/commands/tests/e2e/helpers.ts index eef65fc81..18af318db 100644 --- a/packages/commands/tests/e2e/helpers.ts +++ b/packages/commands/tests/e2e/helpers.ts @@ -10,8 +10,12 @@ import { } from "e2e/output"; import { captureRegistryHelp } from "e2e/registry-smoke"; import { runNodeMain, type RunCliResult } from "e2e/runner"; -import type { AnyCommand } from "bailian-cli-core"; -import { CommandRegistry, resolve } from "bailian-cli-runtime"; +import { buildIsolatedCliEnv } from "e2e/isolation"; +import { runInProcess } from "e2e/in-process"; +import { CommandRegistry, createCli, resolve } from "bailian-cli-runtime"; +import { loadRouteCommands } from "./harness/routes.ts"; +import { E2E_HARNESS_IDENTITY } from "./harness/identity.ts"; +import { withHarnessUpdateFixture } from "./harness/update-fixture.ts"; import type { E2eRouteExports } from "./topic-routes.ts"; export { @@ -47,10 +51,8 @@ const e2eDir = dirname(fileURLToPath(import.meta.url)); const harnessMainTs = join(e2eDir, "harness", "main.ts"); const registryCache = new WeakMap>(); -/** `packages/commands` 根目录 */ export const commandsPackageRoot = join(e2eDir, "..", ".."); -/** E2E fixtures 目录 */ export const e2eFixturesDir = join(e2eDir, "fixtures"); function serializeRoutes(routes: E2eRouteExports): string { @@ -63,23 +65,14 @@ function getCommandRegistry(routes: E2eRouteExports): Promise { const cachedRegistry = registryCache.get(routes); if (cachedRegistry) return cachedRegistry; - const registryPromise = import("bailian-cli-commands").then((commandExports) => { - const commandLibrary = commandExports as Record; - const commands: Record = {}; - for (const [path, exportName] of Object.entries(routes)) { - const command = commandLibrary[exportName]; - if (typeof command !== "object" || command === null || !("run" in command)) { - throw new Error(`Unknown bailian-cli-commands export: ${exportName}`); - } - commands[path] = command as AnyCommand; - } - return new CommandRegistry(commands, "bl"); - }); + const registryPromise = loadRouteCommands(routes).then( + (commands) => new CommandRegistry(commands, "bl"), + ); registryCache.set(routes, registryPromise); return registryPromise; } -/** 使用 topic 最小路由在当前 Vitest worker 中渲染命令 help,不启动 CLI 子进程。 */ +/** In-worker help via topic routes (no CLI subprocess). */ export async function captureCommandHelp( routes: E2eRouteExports, commandPath: string[], @@ -92,7 +85,7 @@ export async function captureCommandHelp( return captureRegistryHelp(registry, commandPath); } -/** 与 runCommandE2e 的结果结构兼容,但 help 在当前 Vitest worker 中渲染。 */ +/** Same result shape as runCommandE2e; help is rendered in-worker. */ export async function runCommandHelp( routes: E2eRouteExports, args: string[], @@ -104,20 +97,65 @@ export async function runCommandHelp( return { stdout: "", stderr, exitCode: 0 }; } -/** - * 通过 harness 子进程执行命令 E2E。 - * `routes` 为本用例所需的最小 path → export 映射,不维护全量产品 map。 - */ +export interface CommandE2eRunOptions { + /** Copy host console access_token into the isolated config. */ + seedCredentials?: boolean; +} + +/** Harness subprocess E2E; `routes` is the minimal path → export map for this topic. */ export async function runCommandE2e( routes: E2eRouteExports, args: string[], envOverrides: NodeJS.ProcessEnv = {}, + options: CommandE2eRunOptions = {}, ): Promise { return runNodeMain(harnessMainTs, args, { cwd: commandsPackageRoot, + seedCredentials: options.seedCredentials, env: { BAILIAN_E2E_ROUTES: serializeRoutes(routes), ...envOverrides, }, }); } + +/** Console live: isolate and seed host access_token. */ +export async function runConsoleCommandE2e( + routes: E2eRouteExports, + args: string[], + envOverrides: NodeJS.ProcessEnv = {}, +): Promise { + return runCommandE2e(routes, args, envOverrides, { seedCredentials: true }); +} + +/** + * Offline opt-in: real createCli in-process with e2e/isolation. + * Falls back to a subprocess when NODE_OPTIONS or HTTP(S)_PROXY is set. + */ +export async function runCommandInProcess( + routes: E2eRouteExports, + args: string[], + envOverrides: NodeJS.ProcessEnv = {}, +): Promise { + const environment = { ...process.env, ...envOverrides }; + if ( + environment.NODE_OPTIONS || + environment.HTTP_PROXY || + environment.HTTPS_PROXY || + environment.http_proxy || + environment.https_proxy + ) { + return runCommandE2e(routes, args, envOverrides); + } + const isolated = buildIsolatedCliEnv({ overrides: envOverrides }); + try { + const commands = await loadRouteCommands(routes); + return await runInProcess( + () => withHarnessUpdateFixture(() => createCli(commands, E2E_HARNESS_IDENTITY).run(args)), + args, + { cwd: commandsPackageRoot, env: isolated.env, entrypoint: harnessMainTs }, + ); + } finally { + isolated.dispose(); + } +} diff --git a/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts index 6d0b8f6de..26979329d 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts @@ -13,6 +13,7 @@ import { parseStdoutJson, runCommandHelp, runCommandE2e, + runCommandInProcess, } from "../helpers.ts"; import { KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, @@ -89,7 +90,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk add: --field 无 = 报 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "add", @@ -103,7 +104,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk add: 无任何内容 flag 报 USAGE (2)", async () => { - const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "add", @@ -116,7 +117,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk add: --content 与 --content-file 同传 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "add", @@ -132,7 +133,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk add: --content 6001 字符 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "add", @@ -146,7 +147,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk add: --title 51 字符 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "add", @@ -166,7 +167,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { "--image-url", `https://example.com/img${urlIndex}.png`, ]).flat(); - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "add", @@ -179,16 +180,10 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk add: dry-run 断言 pipelineId + field 键值原样", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "chunk", - "add", - "--index-id", - "idx_test", - "--field", - "列A=v=1", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + ["knowledge", "chunk", "add", "--index-id", "idx_test", "--field", "列A=v=1", ...COMMON], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.endpoint).toMatch(/chunk\/create/); @@ -201,16 +196,19 @@ describe("e2e: knowledge chunk 组 (静态)", () => { const contentDir = mkdtempSync(join(tmpdir(), "chunk-add-cf-")); const contentPath = join(contentDir, "chunk.md"); writeFileSync(contentPath, "content-file channel fixture body"); - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "chunk", - "add", - "--index-id", - "idx_test", - "--content-file", - contentPath, - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + [ + "knowledge", + "chunk", + "add", + "--index-id", + "idx_test", + "--content-file", + contentPath, + ...COMMON, + ], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); const fieldMap = data.request?.field as Record | undefined; @@ -218,22 +216,25 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk add: dry-run 断言 title/image_urls 键名映射", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "chunk", - "add", - "--index-id", - "idx_test", - "--content", - "hello", - "--title", - "intro", - "--image-url", - "https://example.com/a.png", - "--image-url", - "https://example.com/b.png", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + [ + "knowledge", + "chunk", + "add", + "--index-id", + "idx_test", + "--content", + "hello", + "--title", + "intro", + "--image-url", + "https://example.com/a.png", + "--image-url", + "https://example.com/b.png", + ...COMMON, + ], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); const fieldMap = data.request?.field as Record | undefined; @@ -247,16 +248,10 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk list: dry-run 断言 pageNum/pageSize 键名 (camelCase 坑位)", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "chunk", - "list", - "--index-id", - "idx_test", - "--page-number", - "2", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + ["knowledge", "chunk", "list", "--index-id", "idx_test", "--page-number", "2", ...COMMON], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.request?.pageNum).toBe(2); @@ -265,7 +260,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk list: --page-size 101 报 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "list", @@ -279,7 +274,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk update: content 9 字符 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "update", @@ -301,7 +296,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { const contentDir = mkdtempSync(join(tmpdir(), "chunk-upd-cf-")); const contentPath = join(contentDir, "short.txt"); writeFileSync(contentPath, "123456789"); - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "update", @@ -319,7 +314,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk update: 无修改项 USAGE (2)", async () => { - const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "update", @@ -337,7 +332,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk update: --content 与 --content-file 同传 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "update", @@ -357,7 +352,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk update: --title 51 字符 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "update", @@ -375,7 +370,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk update: --exclude 与 --include 互斥 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "update", @@ -393,20 +388,23 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk update: dry-run 断言 isDisplayedChunkContent 缺省 true", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "chunk", - "update", - "--index-id", - "idx_test", - "--chunk-id", - "chunk_test", - "--doc-id", - "file_test", - "--content", - "corrected content text", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + [ + "knowledge", + "chunk", + "update", + "--index-id", + "idx_test", + "--chunk-id", + "chunk_test", + "--doc-id", + "file_test", + "--content", + "corrected content text", + ...COMMON, + ], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.request?.pipelineId).toBe("idx_test"); @@ -418,15 +416,10 @@ describe("e2e: knowledge chunk 组 (静态)", () => { "--chunk-id", `chunk_${chunkIndex}`, ]).flat(); - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "chunk", - "delete", - "--index-id", - "idx_test", - ...chunkIdArgs, - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + ["knowledge", "chunk", "delete", "--index-id", "idx_test", ...chunkIdArgs, ...COMMON], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson<{ batches: Array<{ request: { chunkIds: string[] } }> }>(stdout); expect(data.batches).toHaveLength(2); @@ -435,7 +428,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => { }); test("chunk delete: 无 --yes 返回确认请求 (7)", async () => { - const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "chunk", "delete", @@ -459,15 +452,18 @@ describe("e2e: knowledge chunk 组 (静态)", () => { describe("e2e: kb stats / category / file / connector / import-oss (静态)", () => { test("kb stats: dry-run 断言秒级字符串时间戳", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "stats", - "--index-id", - "idx_test", - "--start", - "2026-07-30T00:00:00Z", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + [ + "knowledge", + "stats", + "--index-id", + "idx_test", + "--start", + "2026-07-30T00:00:00Z", + ...COMMON, + ], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.endpoint).toMatch(/index\/monitor/); @@ -476,22 +472,17 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("kb stats: 13 位毫秒时间戳归一为秒", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "stats", - "--index-id", - "idx_test", - "--start", - "1785369600000", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + ["knowledge", "stats", "--index-id", "idx_test", "--start", "1785369600000", ...COMMON], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.request?.startTimestamp).toBe("1785369600"); }); test("kb stats: 非法时间报 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "stats", "--index-id", @@ -504,14 +495,10 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("category list: dry-run 断言 type=UNSTRUCTURED 与 maxResult 单数键名", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "category", - "list", - "--max-result", - "5", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + ["knowledge", "category", "list", "--max-result", "5", ...COMMON], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.endpoint).toMatch(/listCategory/); @@ -521,7 +508,7 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("category add: --name 21 字符 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "category", "add", @@ -533,18 +520,21 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("category add: dry-run 断言 parentCategoryId/connectorId 键名映射", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "category", - "add", - "--name", - "sub-cate", - "--parent-id", - "cate_parent", - "--collection-id", - "conn_test", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + [ + "knowledge", + "category", + "add", + "--name", + "sub-cate", + "--parent-id", + "cate_parent", + "--collection-id", + "conn_test", + ...COMMON, + ], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.endpoint).toMatch(/addCategory/); @@ -555,14 +545,10 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("category delete: dry-run 断言 categoryId", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "category", - "delete", - "--category-id", - "cate_test", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + ["knowledge", "category", "delete", "--category-id", "cate_test", ...COMMON], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.endpoint).toMatch(/deleteCategory/); @@ -570,7 +556,7 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("category delete: 无 --yes 返回确认请求 (7)", async () => { - const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "category", "delete", @@ -590,7 +576,7 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("file list: 缺 --category-id 报 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "file", "list", @@ -601,22 +587,25 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("file list: dry-run 断言 fileIds 数组与 fileName/nextToken 键名透传", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "file", - "list", - "--category-id", - "cate_test", - "--name", - "report", - "--file-id", - "file_a", - "--file-id", - "file_b", - "--next-token", - "tok_1", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + [ + "knowledge", + "file", + "list", + "--category-id", + "cate_test", + "--name", + "report", + "--file-id", + "file_a", + "--file-id", + "file_b", + "--next-token", + "tok_1", + ...COMMON, + ], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.endpoint).toMatch(/listFile/); @@ -628,14 +617,10 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("file get: dry-run 断言 fileId", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "file", - "get", - "--file-id", - "file_test", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + ["knowledge", "file", "get", "--file-id", "file_test", ...COMMON], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.endpoint).toMatch(/describeFile/); @@ -643,14 +628,10 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("file delete: dry-run 断言 fileId", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "file", - "delete", - "--file-id", - "file_test", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + ["knowledge", "file", "delete", "--file-id", "file_test", ...COMMON], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.endpoint).toMatch(/deleteFile/); @@ -658,7 +639,7 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("file delete: 无 --yes 返回确认请求 (7)", async () => { - const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "file", "delete", @@ -678,7 +659,7 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("collection create: --name 21 字符 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "collection", "create", @@ -692,7 +673,7 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("collection create: --store-type 非法值 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "collection", "create", @@ -708,7 +689,7 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("collection create: custom 缺 oss 参数 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "collection", "create", @@ -724,16 +705,19 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("collection create: dry-run 断言 FILE + PLATFORM 缺省", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "collection", - "create", - "--name", - "demo", - "--description", - "team docs", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + [ + "knowledge", + "collection", + "create", + "--name", + "demo", + "--description", + "team docs", + ...COMMON, + ], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.request?.connectorType).toBe("FILE"); @@ -742,22 +726,25 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("collection create: custom dry-run 断言 regionId/bucketName 键名映射", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "collection", - "create", - "--name", - "oss-coll", - "--description", - "own bucket", - "--store-type", - "custom", - "--oss-region", - "cn-beijing", - "--oss-bucket", - "my-bucket", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + [ + "knowledge", + "collection", + "create", + "--name", + "oss-coll", + "--description", + "own bucket", + "--store-type", + "custom", + "--oss-region", + "cn-beijing", + "--oss-bucket", + "my-bucket", + ...COMMON, + ], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); const connectorConfig = data.request?.fileConnectorConfig as @@ -773,7 +760,7 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("collection get: 都缺 / 都传 均 USAGE (2)", async () => { - const bothMissing = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const bothMissing = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "collection", "get", @@ -781,7 +768,7 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () "ws_test", ]); expect(bothMissing.exitCode).toBe(2); - const bothGiven = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const bothGiven = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "collection", "get", @@ -796,14 +783,10 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("collection get: dry-run 断言 connectorName 键名", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "collection", - "get", - "--name", - "my-conn", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + ["knowledge", "collection", "get", "--name", "my-conn", ...COMMON], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.endpoint).toMatch(/getConnector/); @@ -812,7 +795,7 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("doc import-oss: 缺 --bucket 报 USAGE (2)", async () => { - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "doc", "import-oss", @@ -827,23 +810,26 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () }); test("doc import-oss: dry-run 断言 fileDetails 派生与 tag/overwrite/category 映射", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ - "knowledge", - "doc", - "import-oss", - "--bucket", - "my-bucket", - "--region", - "cn-beijing", - "--oss-key", - "docs/a.pdf", - "--category-id", - "cate_x", - "--tag", - "alpha", - "--overwrite", - ...COMMON, - ]); + const { stdout, stderr, exitCode } = await runCommandInProcess( + KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, + [ + "knowledge", + "doc", + "import-oss", + "--bucket", + "my-bucket", + "--region", + "cn-beijing", + "--oss-key", + "docs/a.pdf", + "--category-id", + "cate_x", + "--tag", + "alpha", + "--overwrite", + ...COMMON, + ], + ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson(stdout); expect(data.endpoint).toMatch(/addFilesFromAuthorizedOss/); @@ -857,7 +843,7 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () test("doc import-oss: 11 个 --tag 报 USAGE (2)", async () => { const tagArgs = Array.from({ length: 11 }, (_, tagIndex) => ["--tag", `t${tagIndex}`]).flat(); - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "doc", "import-oss", @@ -878,7 +864,7 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", () "--oss-key", `docs/f${keyIndex}.pdf`, ]).flat(); - const { exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [ "knowledge", "doc", "import-oss", diff --git a/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts index 5b3d4e941..2c4551c7d 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts @@ -5,7 +5,13 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vite-plus/test"; -import { isKbAdminE2EReady, parseStdoutJson, runCommandHelp, runCommandE2e } from "../helpers.ts"; +import { + isKbAdminE2EReady, + parseStdoutJson, + runCommandHelp, + runCommandE2e, + runCommandInProcess, +} from "../helpers.ts"; import { KNOWLEDGE_SERVICE_ROUTES } from "../topic-routes.ts"; import { pickDifferentAgentModel } from "./verified-models.ts"; @@ -83,7 +89,7 @@ describe("e2e: knowledge service list", () => { }); test("--dry-run 断言 body agent_scene 与分页走 body", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(KNOWLEDGE_SERVICE_ROUTES, [ "knowledge", "service", "list", @@ -106,7 +112,7 @@ describe("e2e: knowledge service list", () => { }); test("--dry-run 断言 --status/--name/--agent-id 的 snake_case body 映射", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(KNOWLEDGE_SERVICE_ROUTES, [ "knowledge", "service", "list", @@ -145,7 +151,7 @@ describe("e2e: knowledge service get / create / copy", () => { }); test("get: --dry-run 断言 agent_version 透传", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(KNOWLEDGE_SERVICE_ROUTES, [ "knowledge", "service", "get", @@ -226,7 +232,7 @@ describe("e2e: knowledge service get / create / copy", () => { }); test("create: --dry-run + --index-id 断言最简 kb_search_configs", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(KNOWLEDGE_SERVICE_ROUTES, [ "knowledge", "service", "create", @@ -251,7 +257,7 @@ describe("e2e: knowledge service get / create / copy", () => { }); test("copy: --dry-run 断言 body agent_id", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(KNOWLEDGE_SERVICE_ROUTES, [ "knowledge", "service", "copy", @@ -414,7 +420,7 @@ describe("e2e: knowledge service update", () => { const fixtureDir = mkdtempSync(join(tmpdir(), "svc-update-e2e-")); const badJson = join(fixtureDir, "bad.json"); writeFileSync(badJson, "{not json"); - const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ + const { exitCode } = await runCommandInProcess(KNOWLEDGE_SERVICE_ROUTES, [ "knowledge", "service", "update", @@ -430,7 +436,7 @@ describe("e2e: knowledge service update", () => { }); test("--dry-run + --version-desc 断言 body 与标量合并说明", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(KNOWLEDGE_SERVICE_ROUTES, [ "knowledge", "service", "update", @@ -475,7 +481,7 @@ describe("e2e: knowledge service deploy / delete (危险)", () => { }); test("deploy: --dry-run 断言 body", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(KNOWLEDGE_SERVICE_ROUTES, [ "knowledge", "service", "deploy", @@ -516,7 +522,7 @@ describe("e2e: knowledge service deploy / delete (危险)", () => { }); test("delete: --dry-run 断言 body", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(KNOWLEDGE_SERVICE_ROUTES, [ "knowledge", "service", "delete", diff --git a/packages/commands/tests/e2e/log.e2e.test.ts b/packages/commands/tests/e2e/log.e2e.test.ts index d120252f9..a054d8d1b 100644 --- a/packages/commands/tests/e2e/log.e2e.test.ts +++ b/packages/commands/tests/e2e/log.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { parseStdoutJson, runCommandE2e, runCommandInProcess } from "./helpers.ts"; import { LOG_ROUTES } from "./topic-routes.ts"; // 只覆盖 help / 参数校验 / dry-run;真实调用依赖 console 凭证与已开启的日志投递。 @@ -30,7 +30,7 @@ describe("e2e: log", () => { }); test("log audit list --dry-run 输出审计口径查询参数", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(LOG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(LOG_ROUTES, [ "log", "audit", "list", @@ -84,7 +84,7 @@ describe("e2e: log", () => { }); test("log audit count --dry-run 输出 AuditLog 计数请求", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(LOG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(LOG_ROUTES, [ "log", "audit", "count", @@ -104,7 +104,7 @@ describe("e2e: log", () => { }); test("log audit enable --dry-run 输出 AuditLog 开通链路", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(LOG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(LOG_ROUTES, [ "log", "audit", "enable", @@ -125,7 +125,7 @@ describe("e2e: log", () => { }); test("log audit disable --dry-run 输出 AuditLog 关闭请求", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(LOG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(LOG_ROUTES, [ "log", "audit", "disable", @@ -143,7 +143,7 @@ describe("e2e: log", () => { }); test("log inference list --dry-run 输出推理口径查询参数", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(LOG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(LOG_ROUTES, [ "log", "inference", "list", @@ -177,7 +177,7 @@ describe("e2e: log", () => { }); test("log inference get --dry-run 输出列表 + 回捞两段请求", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(LOG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(LOG_ROUTES, [ "log", "inference", "get", @@ -194,7 +194,7 @@ describe("e2e: log", () => { }); test("log inference count --dry-run 输出 InferenceLog 计数请求", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(LOG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(LOG_ROUTES, [ "log", "inference", "count", @@ -214,7 +214,7 @@ describe("e2e: log", () => { }); test("log inference enable --dry-run 输出 InferenceLog 开通链路", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(LOG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(LOG_ROUTES, [ "log", "inference", "enable", @@ -235,7 +235,7 @@ describe("e2e: log", () => { }); test("log inference disable --dry-run 输出 InferenceLog 关闭请求", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(LOG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(LOG_ROUTES, [ "log", "inference", "disable", @@ -265,7 +265,7 @@ describe("e2e: log", () => { }); test("log trace list --dry-run 输出 trace 查询", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(LOG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(LOG_ROUTES, [ "log", "trace", "list", @@ -298,7 +298,7 @@ describe("e2e: log", () => { }); test("log trace stats --dry-run 输出统计 API", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(LOG_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(LOG_ROUTES, [ "log", "trace", "stats", diff --git a/packages/commands/tests/e2e/managed-agent.e2e.test.ts b/packages/commands/tests/e2e/managed-agent.e2e.test.ts index bae03e916..a6d1b400d 100644 --- a/packages/commands/tests/e2e/managed-agent.e2e.test.ts +++ b/packages/commands/tests/e2e/managed-agent.e2e.test.ts @@ -11,7 +11,13 @@ import { } from "@openagentpack/sdk"; import { afterEach, describe, expect, test } from "vite-plus/test"; import { parse } from "yaml"; -import { e2eFixturesDir, parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts"; +import { + e2eFixturesDir, + parseStdoutJson, + runCommandHelp, + runCommandE2e, + runCommandInProcess, +} from "./helpers.ts"; import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts"; const AGENTS_DEPLOYMENT_YAML = join(e2eFixturesDir, "managed-agent", "agents-deployment.yaml"); @@ -133,7 +139,7 @@ describe("e2e: managed-agent", () => { }); test("plan --dry-run 通过 SDK planner 规划原生 Bailian Deployment", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "plan", "--dry-run", @@ -196,7 +202,7 @@ describe("e2e: managed-agent", () => { }); test("plan --dry-run 在 Deployment 安全诊断失败时阻断计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "plan", "--dry-run", @@ -238,7 +244,7 @@ describe("e2e: managed-agent", () => { }); test("managed-agent apply 不再接受 --refresh-only", async () => { - const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "apply", "--dry-run", @@ -347,7 +353,7 @@ describe("e2e: managed-agent", () => { ], }), ); - const dryBuild = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const dryBuild = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "project", "build", @@ -636,7 +642,7 @@ defaults: test("managed-agent agent create 的 Skill type 默认是 custom", async () => { const sourceBefore = await readFile(AGENTS_YAML, "utf8"); - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "agent", "create", @@ -679,7 +685,7 @@ defaults: await writeFile(configPath, configSource, "utf8"); try { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "agent", "create", @@ -752,7 +758,7 @@ defaults: await writeFile(configPath, configSource, "utf8"); try { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "agent", "create", @@ -1200,7 +1206,7 @@ vaults: }); test("deployment create 拒绝非法 Event 与不完整 Schedule", async () => { - const mixedEventInputs = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const mixedEventInputs = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "deployment", "create", @@ -1217,7 +1223,7 @@ vaults: expect(mixedEventInputs.exitCode).toBe(2); expect(mixedEventInputs.stderr).toMatch(/--message and --event cannot be used together/i); - const invalidEvent = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const invalidEvent = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "deployment", "create", @@ -1394,7 +1400,7 @@ vaults: }); test("session event send 拒绝字符串 content", async () => { - const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "session", "event", @@ -1410,7 +1416,7 @@ vaults: }); test("file upload 不再暴露多 Provider 专属选项", async () => { - const providerResult = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const providerResult = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "file", "upload", @@ -1423,7 +1429,7 @@ vaults: expect(providerResult.exitCode).toBe(2); expect(providerResult.stderr).toMatch(/(?:Unknown flag|未知参数).*--provider/i); - const purposeResult = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const purposeResult = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "file", "upload", @@ -1481,7 +1487,7 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => ["deployment pause", ["deployment", "pause", "--deployment-id", "dep_e2e"]], ["deployment unpause", ["deployment", "unpause", "--deployment-id", "dep_e2e"]], ])("%s --dry-run 在构建 SDK runtime 前短路", async (_label, commandArgs) => { - const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", ...commandArgs, "--dry-run", @@ -1492,7 +1498,7 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => }); test("init --dry-run 仅输出计划,不创建文件", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "init", "--dry-run", @@ -1508,7 +1514,7 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => }); test("project workbench --dry-run 仅输出目录项目启动计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "project", "workbench", @@ -1531,7 +1537,7 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => }); test("init 不再接受 --provider", async () => { - const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "init", "--dry-run", @@ -1546,7 +1552,7 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => const parent = await mkdtemp(join(tmpdir(), "bailian-managed-agent-project-dry-run-")); projectDirectories.push(parent); const projectRoot = join(parent, "project"); - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "project", "init", @@ -1564,7 +1570,7 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => }); test("apply --dry-run 仅输出计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "apply", "--dry-run", @@ -1578,7 +1584,7 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => test("agent create --dry-run 自动生成 key 且不改 YAML", async () => { const sourceBefore = await readFile(AGENTS_YAML, "utf8"); - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "agent", "create", @@ -1624,7 +1630,7 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => ["vault", ["vault", "create", "--name", "Create Vault"], "vault", "create-vault"], ])("%s create --dry-run 只规划目标资源且不改 YAML", async (_label, commandArgs, type, key) => { const sourceBefore = await readFile(AGENTS_YAML, "utf8"); - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", ...commandArgs, "--dry-run", @@ -1665,7 +1671,7 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => "utf8", ); try { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "skill", "create", @@ -1721,7 +1727,7 @@ agents: await seedTrackedResources(configPath, [ { type: "agent", name: "assistant", provider: "bailian" }, ]); - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "deployment", "create", @@ -1781,7 +1787,7 @@ vaults: await seedTrackedResources(configPath, [ { type: "vault", name: "production", provider: "bailian" }, ]); - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "vault", "credential", @@ -1823,7 +1829,7 @@ vaults: }); test("destroy --dry-run 仅输出计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "destroy", "--dry-run", @@ -1837,7 +1843,7 @@ vaults: }); test("session create --dry-run 仅输出计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "session", "create", @@ -1853,7 +1859,7 @@ vaults: }); test("session delete --dry-run 仅输出计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "session", "delete", @@ -1869,7 +1875,7 @@ vaults: }); test("session send --dry-run 仅输出计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "session", "send", @@ -1892,7 +1898,7 @@ vaults: }); test("session run --dry-run 仅输出计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "session", "run", @@ -1911,7 +1917,7 @@ vaults: }); test("state rm --dry-run 仅输出计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "state", "rm", @@ -1927,7 +1933,7 @@ vaults: }); test("state import --dry-run 仅输出计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MANAGED_AGENT_ROUTES, [ "managed-agent", "state", "import", diff --git a/packages/commands/tests/e2e/mcp.e2e.test.ts b/packages/commands/tests/e2e/mcp.e2e.test.ts index 6cb744720..70bc84843 100644 --- a/packages/commands/tests/e2e/mcp.e2e.test.ts +++ b/packages/commands/tests/e2e/mcp.e2e.test.ts @@ -3,7 +3,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parse as parseToml } from "smol-toml"; import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts"; +import { + isDashScopeE2EReady, + parseStdoutJson, + runCommandHelp, + runCommandE2e, + runCommandInProcess, +} from "./helpers.ts"; import { MCP_ROUTES } from "./topic-routes.ts"; /** @@ -72,7 +78,7 @@ describe("e2e: mcp", () => { test("mcp connect --dry-run 输出端点和 Header 名但不写配置", async () => { const tempHome = mkdtempSync(join(tmpdir(), "bl-mcp-connect-dry-")); try { - const { stdout, stderr, exitCode } = await runCommandE2e( + const { stdout, stderr, exitCode } = await runCommandInProcess( MCP_ROUTES, [ "mcp", @@ -162,7 +168,7 @@ describe("e2e: mcp", () => { "sk-test-secret", ); - const preview = await runCommandE2e( + const preview = await runCommandInProcess( MCP_ROUTES, [ "mcp", @@ -203,7 +209,7 @@ describe("e2e: mcp", () => { }); test("mcp list --dry-run 仅打印计划且固定 activated=1", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MCP_ROUTES, [ "mcp", "list", "--dry-run", @@ -241,7 +247,7 @@ describe("e2e: mcp", () => { }); test("mcp list --dry-run 自定义 --console-region 透传", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MCP_ROUTES, [ "mcp", "list", "--dry-run", @@ -256,7 +262,7 @@ describe("e2e: mcp", () => { }); test("mcp tools --server --dry-run 输出 /api/v1/mcps//mcp 形态 URL", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MCP_ROUTES, [ "mcp", "tools", "--server", @@ -275,7 +281,7 @@ describe("e2e: mcp", () => { }); test("mcp tools --url 覆盖 baseUrl 约定", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MCP_ROUTES, [ "mcp", "tools", "--server", @@ -299,7 +305,7 @@ describe("e2e: mcp", () => { }); test("mcp call --target --dry-run 输出工具调用计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MCP_ROUTES, [ "mcp", "call", "--target", @@ -325,7 +331,7 @@ describe("e2e: mcp", () => { }); test("mcp call --json 与 --arg 合并(arg 覆盖 json),--query 等价 arg.query", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MCP_ROUTES, [ "mcp", "call", "--target", diff --git a/packages/commands/tests/e2e/monitor.e2e.test.ts b/packages/commands/tests/e2e/monitor.e2e.test.ts index dbb20bb67..2f22b82ab 100644 --- a/packages/commands/tests/e2e/monitor.e2e.test.ts +++ b/packages/commands/tests/e2e/monitor.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { parseStdoutJson, runCommandE2e, runCommandInProcess } from "./helpers.ts"; import { MONITOR_ROUTES } from "./topic-routes.ts"; // 只覆盖 help / 参数校验 / dry-run;真实调用依赖 console 凭证与已开通的监控服务。 @@ -62,7 +62,7 @@ describe("e2e: monitor", () => { }); test("monitor metrics --dry-run 输出 reqDTO 与自动 step", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MONITOR_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MONITOR_ROUTES, [ "monitor", "metrics", "--metric", @@ -106,7 +106,7 @@ describe("e2e: monitor", () => { }); test("monitor overview --dry-run 输出统计 API", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MONITOR_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MONITOR_ROUTES, [ "monitor", "overview", "--model", @@ -125,7 +125,7 @@ describe("e2e: monitor", () => { }); test("monitor errors --dry-run 输出错误码统计 API", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MONITOR_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MONITOR_ROUTES, [ "monitor", "errors", "--dry-run", @@ -140,7 +140,7 @@ describe("e2e: monitor", () => { }); test("monitor delivery enable --dry-run 输出开通链路 API", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MONITOR_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MONITOR_ROUTES, [ "monitor", "delivery", "enable", @@ -159,7 +159,7 @@ describe("e2e: monitor", () => { }); test("monitor delivery disable --dry-run 输出 Monitor 开关关闭请求", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(MONITOR_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(MONITOR_ROUTES, [ "monitor", "delivery", "disable", @@ -178,7 +178,7 @@ describe("e2e: monitor", () => { }); test("--start-time 晚于 --end-time 报错", async () => { - const { stderr, exitCode } = await runCommandE2e(MONITOR_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(MONITOR_ROUTES, [ "monitor", "overview", "--start-time", diff --git a/packages/commands/tests/e2e/quota.e2e.test.ts b/packages/commands/tests/e2e/quota.e2e.test.ts index 52a45a016..0f4dce5a4 100644 --- a/packages/commands/tests/e2e/quota.e2e.test.ts +++ b/packages/commands/tests/e2e/quota.e2e.test.ts @@ -6,6 +6,7 @@ import { parseStdoutJson, runCommandHelp, runCommandE2e, + runConsoleCommandE2e, } from "./helpers.ts"; import { QUOTA_ROUTES } from "./topic-routes.ts"; @@ -359,13 +360,13 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: quota(DashScope)", () => { describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { test("quota check 文本输出包含英文表头", async () => { - const result = await runCommandE2e(QUOTA_ROUTES, ["quota", "check", "--output", "text"]); + const result = await runConsoleCommandE2e(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 runCommandE2e(QUOTA_ROUTES, [ + const result = await runConsoleCommandE2e(QUOTA_ROUTES, [ "quota", "check", "--model", @@ -378,7 +379,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota check --model 逗号分隔多模型", async () => { - const result = await runCommandE2e(QUOTA_ROUTES, [ + const result = await runConsoleCommandE2e(QUOTA_ROUTES, [ "quota", "check", "--model", @@ -391,7 +392,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { }); test("quota check JSON 输出包含用量和限额字段", async () => { - const result = await runCommandE2e(QUOTA_ROUTES, [ + const result = await runConsoleCommandE2e(QUOTA_ROUTES, [ "quota", "check", "--model", diff --git a/packages/commands/tests/e2e/sandbox.e2e.test.ts b/packages/commands/tests/e2e/sandbox.e2e.test.ts index cb6c83d19..932e7489c 100644 --- a/packages/commands/tests/e2e/sandbox.e2e.test.ts +++ b/packages/commands/tests/e2e/sandbox.e2e.test.ts @@ -2,7 +2,12 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCommandE2e as runBaseCommandE2e, runCommandHelp } from "./helpers.ts"; +import { + parseStdoutJson, + runCommandE2e as runBaseCommandE2e, + runCommandInProcess as runBaseCommandInProcess, + runCommandHelp, +} from "./helpers.ts"; import { SANDBOX_ROUTES } from "./topic-routes.ts"; const AUTH_ARGS = ["--api-key", "sk-sandbox-e2e", "--workspace-id", "ws-e2e"]; @@ -26,6 +31,15 @@ function runCommandE2e(routes: typeof SANDBOX_ROUTES, args: string[]) { }); } +function runCommandInProcess(routes: typeof SANDBOX_ROUTES, args: string[]) { + return runBaseCommandInProcess(routes, args, { + BAILIAN_CONFIG_DIR: configDirectory, + DASHSCOPE_BASE_URL: "", + DASHSCOPE_API_KEY: "", + BAILIAN_WORKSPACE_ID: "", + }); +} + describe("e2e: Sandbox command discovery", () => { test("built-in images are discoverable without API Key authentication", async () => { const { stderr, exitCode } = await runCommandHelp(SANDBOX_ROUTES, [ @@ -146,7 +160,7 @@ describe("e2e: Sandbox offline validation and dry-run", () => { ])( "template $operation resolves --image before dry-run output", async ({ operation, args, selector, method }) => { - const { stdout, stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(SANDBOX_ROUTES, [ "sandbox", "template", operation, @@ -170,7 +184,7 @@ describe("e2e: Sandbox offline validation and dry-run", () => { ); test("unknown image selector fails before a request", async () => { - const { stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(SANDBOX_ROUTES, [ "sandbox", "template", "create", @@ -194,7 +208,7 @@ describe("e2e: Sandbox offline validation and dry-run", () => { }); test("create requires a template in flags or body", async () => { - const { stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [ + const { stderr, exitCode } = await runCommandInProcess(SANDBOX_ROUTES, [ "sandbox", "create", ...AUTH_ARGS, @@ -209,7 +223,7 @@ describe("e2e: Sandbox offline validation and dry-run", () => { }); test("create body flags win and request environment values are redacted", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(SANDBOX_ROUTES, [ "sandbox", "create", "--body", @@ -299,7 +313,7 @@ describe("e2e: Sandbox offline validation and dry-run", () => { suffix: "/templates/template-test", }, ])("$name dry-run stays offline", async ({ args, method, suffix }) => { - const { stdout, stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(SANDBOX_ROUTES, [ ...args, ...AUTH_ARGS, "--dry-run", diff --git a/packages/commands/tests/e2e/speech-vocabulary.e2e.test.ts b/packages/commands/tests/e2e/speech-vocabulary.e2e.test.ts index 769411183..327a3ff44 100644 --- a/packages/commands/tests/e2e/speech-vocabulary.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-vocabulary.e2e.test.ts @@ -10,6 +10,7 @@ import { parseStdoutJson, runCommandHelp, runCommandE2e, + runCommandInProcess, } from "./helpers.ts"; import { SPEECH_ROUTES } from "./topic-routes.ts"; @@ -228,7 +229,7 @@ describe("e2e: speech vocabulary", () => { }); test("create object 形态 --dry-run 输出 speech-biasing 信封", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(SPEECH_ROUTES, [ "speech", "vocabulary", "create", @@ -266,7 +267,7 @@ describe("e2e: speech vocabulary", () => { }); test("create array 形态 --dry-run 原样透传 lang", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(SPEECH_ROUTES, [ "speech", "vocabulary", "create", @@ -291,7 +292,7 @@ describe("e2e: speech vocabulary", () => { }); test("create object + --lang --dry-run 下发到每一条", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(SPEECH_ROUTES, [ "speech", "vocabulary", "create", @@ -319,7 +320,7 @@ describe("e2e: speech vocabulary", () => { test("create --words-file --dry-run 读取文件", async () => { const filePath = makeTempJson('{"from-file":4}'); - const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(SPEECH_ROUTES, [ "speech", "vocabulary", "create", @@ -344,7 +345,7 @@ describe("e2e: speech vocabulary", () => { }); test("list --page 2 --dry-run 将 page_index 转为 1", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(SPEECH_ROUTES, [ "speech", "vocabulary", "list", @@ -364,7 +365,7 @@ describe("e2e: speech vocabulary", () => { }); test("get --dry-run 使用 query_vocabulary", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(SPEECH_ROUTES, [ "speech", "vocabulary", "get", @@ -384,7 +385,7 @@ describe("e2e: speech vocabulary", () => { }); test("update --dry-run 无 --yes 也能预览", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(SPEECH_ROUTES, [ "speech", "vocabulary", "update", @@ -413,7 +414,7 @@ describe("e2e: speech vocabulary", () => { }); test("delete --dry-run 无 --yes 也能预览", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + const { stdout, stderr, exitCode } = await runCommandInProcess(SPEECH_ROUTES, [ "speech", "vocabulary", "delete", diff --git a/packages/commands/tests/e2e/token-plan.e2e.test.ts b/packages/commands/tests/e2e/token-plan.e2e.test.ts index 0173d752e..af41f9e1b 100644 --- a/packages/commands/tests/e2e/token-plan.e2e.test.ts +++ b/packages/commands/tests/e2e/token-plan.e2e.test.ts @@ -6,6 +6,7 @@ import { parseStdoutJson, runCommandHelp, runCommandE2e, + runConsoleCommandE2e, } from "./helpers.ts"; import { TOKEN_PLAN_ROUTES } from "./topic-routes.ts"; @@ -83,7 +84,7 @@ describe("e2e: token-plan", () => { describe.skipIf(!isConsoleE2EReady())("e2e: token-plan harness-quota(Console)", () => { test("harness-quota --dry-run 输出两个网关请求计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(TOKEN_PLAN_ROUTES, [ + const { stdout, stderr, exitCode } = await runConsoleCommandE2e(TOKEN_PLAN_ROUTES, [ "token-plan", "harness-quota", "--dry-run", @@ -102,7 +103,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: token-plan harness-quota(Console }); test("harness-quota --type 透传给 harness 列表接口", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(TOKEN_PLAN_ROUTES, [ + const { stdout, stderr, exitCode } = await runConsoleCommandE2e(TOKEN_PLAN_ROUTES, [ "token-plan", "harness-quota", "--type", @@ -117,7 +118,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: token-plan harness-quota(Console }); test("harness-quota --output json 返回权益额度条目", async () => { - const result = await runCommandE2e(TOKEN_PLAN_ROUTES, [ + const result = await runConsoleCommandE2e(TOKEN_PLAN_ROUTES, [ "token-plan", "harness-quota", "--output", @@ -148,7 +149,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: token-plan harness-quota(Console }); test("harness-quota 默认渲染额度框或空态文案", async () => { - const result = await runCommandE2e(TOKEN_PLAN_ROUTES, ["token-plan", "harness-quota"]); + const result = await runConsoleCommandE2e(TOKEN_PLAN_ROUTES, ["token-plan", "harness-quota"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toMatch( diff --git a/packages/commands/tests/e2e/usage-coding-plan.e2e.test.ts b/packages/commands/tests/e2e/usage-coding-plan.e2e.test.ts index 8b315eee1..6b6cfce71 100644 --- a/packages/commands/tests/e2e/usage-coding-plan.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-coding-plan.e2e.test.ts @@ -4,7 +4,7 @@ import { isConsoleE2EReady, parseStdoutJson, runCommandHelp, - runCommandE2e, + runConsoleCommandE2e, } from "./helpers.ts"; import { USAGE_ROUTES } from "./topic-routes.ts"; @@ -32,7 +32,7 @@ describe("e2e: usage coding-plan", () => { describe.skipIf(!isConsoleE2EReady())("e2e: usage coding-plan(Console)", () => { test("usage coding-plan --dry-run 输出网关请求计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + const { stdout, stderr, exitCode } = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "coding-plan", "--dry-run", @@ -52,7 +52,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage coding-plan(Console)", () }); test("usage coding-plan --output json 返回窗口结构", async () => { - const result = await runCommandE2e(USAGE_ROUTES, ["usage", "coding-plan", "--output", "json"]); + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ + "usage", + "coding-plan", + "--output", + "json", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); const data = parseStdoutJson<{ @@ -69,7 +74,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage coding-plan(Console)", () }); test("usage coding-plan 默认渲染生成时间与额度窗口或无订阅提示", async () => { - const result = await runCommandE2e(USAGE_ROUTES, ["usage", "coding-plan"]); + const result = await runConsoleCommandE2e(USAGE_ROUTES, ["usage", "coding-plan"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); if (result.stdout.includes("No active Coding Plan subscription found.")) return; diff --git a/packages/commands/tests/e2e/usage-free.e2e.test.ts b/packages/commands/tests/e2e/usage-free.e2e.test.ts index cef06c9c9..46f146581 100644 --- a/packages/commands/tests/e2e/usage-free.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-free.e2e.test.ts @@ -4,7 +4,7 @@ import { isConsoleAuthFailure, parseStdoutJson, runCommandHelp, - runCommandE2e, + runConsoleCommandE2e, } from "./helpers.ts"; import { USAGE_ROUTES } from "./topic-routes.ts"; @@ -26,7 +26,7 @@ describe("e2e: usage free", () => { describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { test("usage free --dry-run --model 输出请求参数不发起调用", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + const { stdout, stderr, exitCode } = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -45,7 +45,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --dry-run --model 逗号分隔多个模型", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + const { stdout, stderr, exitCode } = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -62,7 +62,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --dry-run --model 重复模型名自动去重", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + const { stdout, stderr, exitCode } = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -79,7 +79,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --dry-run --model 逗号间有空格也能正确解析", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + const { stdout, stderr, exitCode } = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -96,7 +96,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --dry-run 不指定 --model 传全量模型列表", async () => { - const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + const { stderr, exitCode } = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--dry-run", @@ -107,7 +107,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model 单模型查询返回 JSON 结果", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -120,7 +120,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model 单模型文本输出包含表头", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -133,7 +133,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model 文本输出包含模型名", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -146,7 +146,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model 逗号分隔多模型文本输出包含所有模型", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -159,7 +159,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model 文本输出包含正确的 Type 列", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -172,7 +172,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model quotaStatus 为 UNKNOWN 时 Auto-Stop 显示 Unsupported", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -185,7 +185,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model quotaStatus 为 UNKNOWN 时额度显示为 -", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -198,7 +198,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model 不存在的模型仍返回表格行", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -211,7 +211,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model Auto-Stop 显示 ON、OFF 或 Unsupported", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", @@ -224,7 +224,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => { }); test("usage free --model --console-region cn-beijing 指定区域查询", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "free", "--model", diff --git a/packages/commands/tests/e2e/usage-stats.e2e.test.ts b/packages/commands/tests/e2e/usage-stats.e2e.test.ts index 643eb169c..691fb15b5 100644 --- a/packages/commands/tests/e2e/usage-stats.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-stats.e2e.test.ts @@ -4,7 +4,7 @@ import { isConsoleAuthFailure, parseStdoutJson, runCommandHelp, - runCommandE2e, + runConsoleCommandE2e, } from "./helpers.ts"; import { USAGE_ROUTES } from "./topic-routes.ts"; import { buildSources } from "bailian-cli-core"; @@ -27,7 +27,12 @@ async function fetchDefaultWorkspaceId(): Promise { const staticId = getStaticWorkspaceId(); if (staticId) return staticId; - const result = await runCommandE2e(USAGE_ROUTES, ["workspace", "list", "--output", "json"]); + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ + "workspace", + "list", + "--output", + "json", + ]); if (isConsoleAuthFailure(result) || result.exitCode !== 0) return FALLBACK_WORKSPACE_ID; try { const parsed = JSON.parse(result.stdout); @@ -73,7 +78,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --dry-run 概览模式输出请求参数", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + const { stdout, stderr, exitCode } = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -102,7 +107,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --dry-run --days 30 时间跨度约 30 天", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + const { stdout, stderr, exitCode } = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -124,7 +129,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --dry-run --model 指定模型使用 list API", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + const { stdout, stderr, exitCode } = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -146,7 +151,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --dry-run --type Text 传递 obsModelType", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + const { stdout, stderr, exitCode } = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -165,7 +170,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats 概览模式返回 JSON 结果", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -178,7 +183,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats 概览文本输出包含英文标签", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -191,7 +196,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats 概览文本输出包含 Token 用量", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -204,7 +209,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --model 单模型文本输出包含英文表头", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -219,7 +224,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --model 逗号分隔多模型返回多行", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -234,7 +239,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --model 不存在的模型返回空表格", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -249,7 +254,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --days 1 短时间范围正常返回", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", @@ -264,7 +269,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => { }); test("usage stats --type Vision 按类型过滤", async () => { - const result = await runCommandE2e(USAGE_ROUTES, [ + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "stats", "--workspace-id", diff --git a/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts index 63fe287cd..88cb79275 100644 --- a/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts @@ -4,7 +4,7 @@ import { isConsoleE2EReady, parseStdoutJson, runCommandHelp, - runCommandE2e, + runConsoleCommandE2e, } from "./helpers.ts"; import { USAGE_ROUTES } from "./topic-routes.ts"; @@ -32,7 +32,7 @@ describe("e2e: usage token-plan", () => { describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () => { test("usage token-plan --dry-run 输出网关请求计划", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [ + const { stdout, stderr, exitCode } = await runConsoleCommandE2e(USAGE_ROUTES, [ "usage", "token-plan", "--dry-run", @@ -46,7 +46,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () = }); test("usage token-plan --output json 返回可用的额度字段", async () => { - const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--output", "json"]); + const result = await runConsoleCommandE2e(USAGE_ROUTES, [ + "usage", + "token-plan", + "--output", + "json", + ]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); const data = parseStdoutJson<{ @@ -60,7 +65,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () = }); test("usage token-plan 默认渲染生成时间与月额度窗口", async () => { - const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan"]); + const result = await runConsoleCommandE2e(USAGE_ROUTES, ["usage", "token-plan"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain("Generated at:"); diff --git a/packages/commands/tests/inventory.test.ts b/packages/commands/tests/inventory.test.ts index ed37168eb..c89cb4a28 100644 --- a/packages/commands/tests/inventory.test.ts +++ b/packages/commands/tests/inventory.test.ts @@ -1,6 +1,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; +import { AGENT_ENV_KEYS } from "bailian-cli-core"; import { expect, test } from "vite-plus/test"; import { listSkills, @@ -10,12 +11,24 @@ import { } from "../src/commands/config/inventory.ts"; import { qwenworkMcpPath } from "../src/commands/mcp/agent-config.ts"; -/** Build an isolated fake $HOME and clean it up afterwards. */ -function withHome(fn: (home: string) => void): void { +/** + * Temp home plus cleared Agent/MCP override env vars. + * Inventory takes an explicit home, but Claude/Hermes probes still read process.env. + */ +function withTempHomeDir(fn: (home: string) => void): void { const home = mkdtempSync(join(tmpdir(), "bl-inv-")); + const saved: Record = {}; + for (const key of AGENT_ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } try { fn(home); } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } rmSync(home, { recursive: true, force: true }); } } @@ -27,7 +40,7 @@ function write(home: string, rel: string, content: string): void { } test("getSkillDetail 返回 SKILL.md 原文,未知 id 返回 null", () => { - withHome((home) => { + withTempHomeDir((home) => { write( home, ".agents/skills/demo/SKILL.md", @@ -43,7 +56,7 @@ test("getSkillDetail 返回 SKILL.md 原文,未知 id 返回 null", () => { }); test("listSkills 解析 SKILL.md frontmatter 与文件数", () => { - withHome((home) => { + withTempHomeDir((home) => { write( home, ".agents/skills/demo/SKILL.md", @@ -68,7 +81,7 @@ test("listSkills 解析 SKILL.md frontmatter 与文件数", () => { }); test("listSkills 将 agent 目录下的软链接视为安装来源", () => { - withHome((home) => { + withTempHomeDir((home) => { write(home, ".agents/skills/bailian-cli/SKILL.md", "---\nname: bailian-cli\n---\n"); // Mimic `skills add`: the agent copy is a symlink back to the global dir. mkdirSync(join(home, ".claude/skills"), { recursive: true }); @@ -81,7 +94,7 @@ test("listSkills 将 agent 目录下的软链接视为安装来源", () => { }); test("listSkills 跨 agent 模块聚合并记录来源", () => { - withHome((home) => { + withTempHomeDir((home) => { // Same skill installed in the global dir and two agent modules. const skillMd = "---\nname: bailian-cli\n---\n# B\n"; write(home, ".agents/skills/bailian-cli/SKILL.md", skillMd); @@ -98,13 +111,13 @@ test("listSkills 跨 agent 模块聚合并记录来源", () => { }); test("listSkills 目录缺失时返回空数组", () => { - withHome((home) => { + withTempHomeDir((home) => { expect(listSkills(home)).toEqual([]); }); }); test("listMcpServers 汇总 codex(toml) 与 claude(json) 的 MCP 定义", () => { - withHome((home) => { + withTempHomeDir((home) => { write(home, ".codex/config.toml", '[mcp_servers.repl]\ncommand = "node"\nargs = ["repl.js"]\n'); write( home, @@ -141,13 +154,13 @@ test("listMcpServers 汇总 codex(toml) 与 claude(json) 的 MCP 定义", () => }); test("listMcpServers 无配置时返回空数组", () => { - withHome((home) => { + withTempHomeDir((home) => { expect(listMcpServers(home)).toEqual([]); }); }); test("listMcpServers 区分 Qoder、Qoder Work 与千问办公 QwenWork", () => { - withHome((home) => { + withTempHomeDir((home) => { write( home, ".qoder/mcp.json", @@ -173,7 +186,7 @@ test("listMcpServers 区分 Qoder、Qoder Work 与千问办公 QwenWork", () => }); test("listAgents 报告安装与已连接 bailian-cli 的状态", () => { - withHome((home) => { + withTempHomeDir((home) => { // Claude Code: installed + configured (base url present). write( home, @@ -242,9 +255,9 @@ test("listAgents 报告安装与已连接 bailian-cli 的状态", () => { }); test("listAgents 通过 HERMES_HOME 探测 relocated 的 hermes 配置", () => { - const prevHermesHome = process.env.HERMES_HOME; - withHome((home) => { - // Hermes home relocated away from ~/.hermes via HERMES_HOME. + withTempHomeDir((home) => { + // Hermes home relocated away from ~/.hermes via HERMES_HOME。 + // withTempHomeDir 已清空宿主 HERMES_HOME;本用例再显式注入。 const hermesHome = join(home, "relocated-hermes"); process.env.HERMES_HOME = hermesHome; write( @@ -258,17 +271,53 @@ test("listAgents 通过 HERMES_HOME 探测 relocated 的 hermes 配置", () => { " api_key: sk-test", ].join("\n"), ); - try { - const byId = Object.fromEntries(listAgents(home).map((a) => [a.id, a])); - // Probe must follow HERMES_HOME, not the hard-coded ~/.hermes (issue #131). - expect(byId.hermes).toMatchObject({ + const byId = Object.fromEntries(listAgents(home).map((agent) => [agent.id, agent])); + // Probe must follow HERMES_HOME, not the hard-coded ~/.hermes (issue #131). + expect(byId.hermes).toMatchObject({ + installed: true, + configured: true, + model: "qwen3-max", + }); + }); +}); + +test("listAgents 不受宿主 CLAUDE_CONFIG_DIR 干扰", () => { + const previousClaude = process.env.CLAUDE_CONFIG_DIR; + const leaked = mkdtempSync(join(tmpdir(), "bl-inv-leaked-claude-")); + try { + process.env.CLAUDE_CONFIG_DIR = leaked; + writeFileSync( + join(leaked, "settings.json"), + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: "https://leaked.example/anthropic", + ANTHROPIC_MODEL: "leaked-model", + }, + }), + ); + withTempHomeDir((home) => { + // helper 清空后,探测应只看传入的 home,而不是宿主 CLAUDE_CONFIG_DIR + expect(process.env.CLAUDE_CONFIG_DIR).toBeUndefined(); + write( + home, + ".claude/settings.json", + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: "https://dashscope.aliyuncs.com/apps/anthropic", + ANTHROPIC_MODEL: "qwen3-max", + }, + }), + ); + const byId = Object.fromEntries(listAgents(home).map((agent) => [agent.id, agent])); + expect(byId["claude-code"]).toMatchObject({ installed: true, configured: true, model: "qwen3-max", }); - } finally { - if (prevHermesHome === undefined) delete process.env.HERMES_HOME; - else process.env.HERMES_HOME = prevHermesHome; - } - }); + }); + } finally { + if (previousClaude === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousClaude; + rmSync(leaked, { recursive: true, force: true }); + } }); diff --git a/packages/commands/tests/mcp-agent-config.test.ts b/packages/commands/tests/mcp-agent-config.test.ts index 747ca6170..dbfc0aa9c 100644 --- a/packages/commands/tests/mcp-agent-config.test.ts +++ b/packages/commands/tests/mcp-agent-config.test.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { enterIsolatedHome, type IsolatedHomeHandle } from "e2e/isolation"; import { parse as parseToml } from "smol-toml"; import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test"; import { @@ -18,14 +19,18 @@ import { let home = ""; let configDir = ""; +let isolation: IsolatedHomeHandle | undefined; beforeEach(() => { - home = mkdtempSync(join(tmpdir(), "bl-mcp-agent-")); - configDir = join(home, ".bailian"); + // MCP path resolution still reads OPENCLAW_CONFIG_PATH / ZCODE_HOME / CODEX_HOME, etc. + isolation = enterIsolatedHome(); + home = isolation.home; + configDir = isolation.configDir; }); afterEach(() => { - rmSync(home, { recursive: true, force: true }); + isolation?.restore(); + isolation = undefined; }); function spec(transport: "streamable-http" | "sse" = "streamable-http"): McpConnectionSpec { @@ -261,6 +266,31 @@ describe("MCP Agent registration", () => { ]); }); + test("resolveMcpAgentTargets ignores host OPENCLAW_CONFIG_PATH", () => { + // Drop beforeEach isolation, inject a host leak path, then re-enter to prove env is cleared. + isolation?.restore(); + isolation = undefined; + const previousOpenclaw = process.env.OPENCLAW_CONFIG_PATH; + const leaked = mkdtempSync(join(tmpdir(), "bl-mcp-openclaw-leak-")); + const leakedConfig = join(leaked, "openclaw.json"); + writeFileSync(leakedConfig, "{}"); + process.env.OPENCLAW_CONFIG_PATH = leakedConfig; + try { + isolation = enterIsolatedHome(); + home = isolation.home; + configDir = isolation.configDir; + expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined(); + mkdirSync(join(home, ".codex"), { recursive: true }); + expect(resolveMcpAgentTargets("all", home)).toEqual(["codex"]); + } finally { + isolation?.restore(); + isolation = undefined; + if (previousOpenclaw === undefined) delete process.env.OPENCLAW_CONFIG_PATH; + else process.env.OPENCLAW_CONFIG_PATH = previousOpenclaw; + rmSync(leaked, { recursive: true, force: true }); + } + }); + test("qoderwork writes ~/.qoderwork/mcp.json", () => { mkdirSync(join(home, ".qoderwork"), { recursive: true }); const [result] = connectMcpAgents({ diff --git a/packages/commands/vite.config.ts b/packages/commands/vite.config.ts index 732788c3d..6a4f23076 100644 --- a/packages/commands/vite.config.ts +++ b/packages/commands/vite.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ test: { globalSetup: "../e2e/src/global-setup.ts", + setupFiles: ["../e2e/src/iso-root-setup.ts"], testTimeout: 60_000, hookTimeout: 60_000, }, diff --git a/packages/core/src/skills/agents.ts b/packages/core/src/skills/agents.ts index 393938324..bca2424b8 100644 --- a/packages/core/src/skills/agents.ts +++ b/packages/core/src/skills/agents.ts @@ -4,11 +4,12 @@ import { lstatSync, mkdirSync, readlinkSync, + realpathSync, rmSync, symlinkSync, } from "node:fs"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; import { getSkillsDir } from "./lock.ts"; import { getHermesHome } from "../utils/agent-home.ts"; @@ -26,10 +27,37 @@ export interface AgentTarget { displayName: string; /** Global directory where this agent reads skills from */ skillsDir: string; - /** 任一存在即判定"本机装了该 agent" */ + /** Any existing path means this agent is installed on the host */ detectDirs: string[]; } +/** + * Test-only switch: when set to `"1"`, `detectInstalledAgents` skips absolute + * detectDirs outside HOME / `XDG_CONFIG_HOME` (e.g. `/etc/codex`, `/Applications/…`, + * and project dirs under cwd). Not for end users. + */ +export const SKIP_HOST_AGENTS_ENV = "BL_SKIP_HOST_AGENTS"; + +/** + * Official Agent config-path override env vars. Clear these in isolated tests + * so detection does not point at the developer machine. + */ +export const AGENT_ENV_KEYS = [ + "CLAUDE_CONFIG_DIR", + "CODEX_HOME", + "VIBE_HOME", + "HERMES_HOME", + "AUTOHAND_HOME", + "GROK_HOME", + "DSH_HOME", + "OPENCLAW_CONFIG_PATH", + "ZCODE_HOME", + "APPDATA", + "LOCALAPPDATA", + "FLATPAK_XDG_CONFIG_HOME", + "PI_CODING_AGENT_DIR", +] as const; + /** * Computed on each call (depends on homedir / XDG_CONFIG_HOME / cwd; easy to override in tests). * Registry mirrors the vercel-labs/skills agent list, minus agents that cannot participate in @@ -246,8 +274,60 @@ export function getAgentTargets(): AgentTarget[] { ]; } +/** + * Resolve to a real absolute path. If the path does not exist yet, realpath + * ancestors and rejoin the suffix so macOS cwd=`/private/var/...` vs + * HOME=`/var/...` is not misclassified as a host system dir. + */ +function canonicalPath(path: string): string { + const absolute = resolve(path); + try { + return realpathSync(absolute); + } catch { + const parts: string[] = []; + let current = absolute; + while (current !== dirname(current)) { + if (existsSync(current)) { + try { + return join(realpathSync(current), ...parts.reverse()); + } catch { + break; + } + } + parts.push(basename(current)); + current = dirname(current); + } + return absolute; + } +} + +function isPathInside(candidate: string, root: string): boolean { + const resolvedCandidate = canonicalPath(candidate); + const resolvedRoot = canonicalPath(root); + return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(resolvedRoot + sep); +} + +/** + * Host-level detect path: absolute and not under the current HOME / + * `XDG_CONFIG_HOME` (e.g. `/etc/codex`, `/Applications/…`). + */ +export function isHostSystemDetectDir(dir: string): boolean { + if (!isAbsolute(dir)) return false; + if (isPathInside(dir, homedir())) return false; + const xdgConfig = process.env.XDG_CONFIG_HOME?.trim(); + if (xdgConfig && isPathInside(dir, xdgConfig)) return false; + return true; +} + +function shouldProbeDetectDir(dir: string): boolean { + if (process.env[SKIP_HOST_AGENTS_ENV] !== "1") return true; + return !isHostSystemDetectDir(dir); +} + export function detectInstalledAgents(): AgentTarget[] { - return getAgentTargets().filter((agent) => agent.detectDirs.some((dir) => existsSync(dir))); + return getAgentTargets().filter((agent) => + agent.detectDirs.some((dir) => shouldProbeDetectDir(dir) && existsSync(dir)), + ); } /** Path equality that respects the host filesystem's case rules (Windows is case-insensitive) */ diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts index 535514516..2f760368f 100644 --- a/packages/core/src/skills/index.ts +++ b/packages/core/src/skills/index.ts @@ -28,10 +28,13 @@ export { extractTarBr, atomicSwap, isSafeEntryName, computeDirContentHash } from export { getAgentTargets, detectInstalledAgents, + isHostSystemDetectDir, linkSkillToAgents, fanOutSkillToAgents, planUnlinkSkillFromAgents, unlinkSkillFromAgents, + AGENT_ENV_KEYS, + SKIP_HOST_AGENTS_ENV, type AgentTarget, type LinkResult, type FanoutOutcome, diff --git a/packages/core/tests/advisor-sync.test.ts b/packages/core/tests/advisor-sync.test.ts index 4f171e602..10d74cba4 100644 --- a/packages/core/tests/advisor-sync.test.ts +++ b/packages/core/tests/advisor-sync.test.ts @@ -1,6 +1,6 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; -import { tmpdir } from "os"; +import { mkdirSync, readFileSync, writeFileSync } from "fs"; import { join } from "path"; +import { withIsolatedHome } from "./helpers/isolation.ts"; import { afterEach, expect, test, vi } from "vite-plus/test"; import { maybeSyncWikiData } from "../src/advisor/sync.ts"; import { readSkillLock } from "../src/skills/lock.ts"; @@ -8,32 +8,6 @@ import { readSkillLock } from "../src/skills/lock.ts"; const WIKI_SKILL_NAME = "bailian-docs-llm-wiki"; const CONTENT_HASH = `sha256:${"a".repeat(64)}`; -/** Isolated HOME/XDG/BAILIAN_CONFIG_DIR; agent config-dir overrides cleared for determinism */ -async function inFakeHome(fn: (home: string) => Promise): Promise { - const saved = { - HOME: process.env.HOME, - XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, - BAILIAN_CONFIG_DIR: process.env.BAILIAN_CONFIG_DIR, - CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, - CODEX_HOME: process.env.CODEX_HOME, - }; - const home = mkdtempSync(join(tmpdir(), "bl-advisor-sync-")); - process.env.HOME = home; - process.env.XDG_CONFIG_HOME = join(home, ".config"); - process.env.BAILIAN_CONFIG_DIR = join(home, ".bailian"); - delete process.env.CLAUDE_CONFIG_DIR; - delete process.env.CODEX_HOME; - try { - await fn(home); - } finally { - for (const [key, value] of Object.entries(saved)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - rmSync(home, { recursive: true, force: true }); - } -} - /** Stub the registry fetch to serve a wiki entry with the given fingerprint */ function stubRegistryIndex() { const fetchMock = vi.fn(async () => ({ @@ -80,7 +54,7 @@ afterEach(() => { }); test("advisor sync: hash-hit backfills fan-out links missing from postinstall lock entry", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { seedPostinstallState(process.env.BAILIAN_CONFIG_DIR!); mkdirSync(join(home, ".claude"), { recursive: true }); const fetchMock = stubRegistryIndex(); @@ -100,7 +74,7 @@ test("advisor sync: hash-hit backfills fan-out links missing from postinstall lo }); test("advisor sync: hash-hit keeps an existing links array untouched", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { seedPostinstallState(process.env.BAILIAN_CONFIG_DIR!); // Upgrade the lock entry to "already fanned out" shape const existingLink = join(home, ".claude", "skills", WIKI_SKILL_NAME); diff --git a/packages/core/tests/helpers/isolation.ts b/packages/core/tests/helpers/isolation.ts new file mode 100644 index 000000000..faa17fee1 --- /dev/null +++ b/packages/core/tests/helpers/isolation.ts @@ -0,0 +1,73 @@ +/** + * In-process test isolation for the core package (core must not depend on e2e). + * Commands / CLI E2E should use `e2e/isolation` (includes child-process env). + */ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AGENT_ENV_KEYS, SKIP_HOST_AGENTS_ENV } from "../../src/skills/agents.ts"; + +export interface IsolatedHomeHandle { + home: string; + configDir: string; + restore: () => void; +} + +/** Enter a temp HOME/config and disable host-level Agent detection. */ +export function enterIsolatedHome(): IsolatedHomeHandle { + const saved: Record = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + BAILIAN_CONFIG_DIR: process.env.BAILIAN_CONFIG_DIR, + [SKIP_HOST_AGENTS_ENV]: process.env[SKIP_HOST_AGENTS_ENV], + }; + for (const key of AGENT_ENV_KEYS) { + saved[key] = process.env[key]; + } + + const root = mkdtempSync(join(tmpdir(), "bl-iso-home-")); + mkdirSync(join(root, "home"), { recursive: true }); + // realpath: align with process.cwd() /private/var prefix on macOS + const home = realpathSync(join(root, "home")); + const configDir = join(home, ".bailian"); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, "config.json"), + `${JSON.stringify({ language: "en-US" }, null, 2)}\n`, + "utf8", + ); + + process.env.HOME = home; + process.env.USERPROFILE = home; + process.env.XDG_CONFIG_HOME = join(home, ".config"); + process.env.BAILIAN_CONFIG_DIR = configDir; + process.env[SKIP_HOST_AGENTS_ENV] = "1"; + for (const key of AGENT_ENV_KEYS) { + delete process.env[key]; + } + + return { + home, + configDir, + restore: () => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +/** Async helper with automatic restore. */ +export async function withIsolatedHome( + fn: (home: string, configDir: string) => Promise, +): Promise { + const handle = enterIsolatedHome(); + try { + await fn(handle.home, handle.configDir); + } finally { + handle.restore(); + } +} diff --git a/packages/core/tests/skills-agents.test.ts b/packages/core/tests/skills-agents.test.ts index 1edbb87bd..159deed1c 100644 --- a/packages/core/tests/skills-agents.test.ts +++ b/packages/core/tests/skills-agents.test.ts @@ -2,14 +2,13 @@ import { existsSync, lstatSync, mkdirSync, - mkdtempSync, readFileSync, readlinkSync, rmSync, writeFileSync, } from "fs"; -import { tmpdir } from "os"; import { join } from "path"; +import { withIsolatedHome } from "./helpers/isolation.ts"; import { expect, test } from "vite-plus/test"; import { detectInstalledAgents, @@ -21,54 +20,6 @@ import { } from "../src/skills/agents.ts"; import { getSkillsDir } from "../src/skills/lock.ts"; -/** - * Isolated environment: HOME/XDG_CONFIG_HOME/BAILIAN_CONFIG_DIR all point to a temp dir, - * so agent detection and the canonical dir never touch the real home. - */ -async function inFakeHome(fn: (home: string) => Promise): Promise { - const saved = { - HOME: process.env.HOME, - XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, - BAILIAN_CONFIG_DIR: process.env.BAILIAN_CONFIG_DIR, - CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, - CODEX_HOME: process.env.CODEX_HOME, - VIBE_HOME: process.env.VIBE_HOME, - HERMES_HOME: process.env.HERMES_HOME, - AUTOHAND_HOME: process.env.AUTOHAND_HOME, - GROK_HOME: process.env.GROK_HOME, - DSH_HOME: process.env.DSH_HOME, - APPDATA: process.env.APPDATA, - FLATPAK_XDG_CONFIG_HOME: process.env.FLATPAK_XDG_CONFIG_HOME, - }; - const home = mkdtempSync(join(tmpdir(), "bl-skill-agents-")); - process.env.HOME = home; - process.env.XDG_CONFIG_HOME = join(home, ".config"); - process.env.BAILIAN_CONFIG_DIR = join(home, ".bailian"); - // Agent config-dir overrides must not leak in from the dev machine - for (const key of [ - "CLAUDE_CONFIG_DIR", - "CODEX_HOME", - "VIBE_HOME", - "HERMES_HOME", - "AUTOHAND_HOME", - "GROK_HOME", - "DSH_HOME", - "APPDATA", - "FLATPAK_XDG_CONFIG_HOME", - ]) { - delete process.env[key]; - } - try { - await fn(home); - } finally { - for (const [key, value] of Object.entries(saved)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - rmSync(home, { recursive: true, force: true }); - } -} - /** Create an installed skill in canonical */ function seedCanonicalSkill(name: string): string { const dir = join(getSkillsDir(), name); @@ -78,7 +29,7 @@ function seedCanonicalSkill(name: string): string { } test("agents: registry mirrors upstream agent list minus non-symlinkable agents", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { const ids = getAgentTargets().map((agent) => agent.id); expect(ids).toContain("universal"); expect(ids).toContain("universal-xdg"); @@ -100,7 +51,7 @@ test("agents: registry mirrors upstream agent list minus non-symlinkable agents" }); test("agents: expanded registry detects per-agent config dirs", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".roo"), { recursive: true }); mkdirSync(join(home, ".trae"), { recursive: true }); mkdirSync(join(home, ".traecli"), { recursive: true }); @@ -123,7 +74,7 @@ test("agents: expanded registry detects per-agent config dirs", async () => { }); test("agents: WorkBuddy/Trae CLI/DSH use their official global skills dirs", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".workbuddy"), { recursive: true }); mkdirSync(join(home, ".traecli"), { recursive: true }); mkdirSync(join(home, ".dsh"), { recursive: true }); @@ -150,7 +101,7 @@ test("agents: WorkBuddy/Trae CLI/DSH use their official global skills dirs", asy }); test("agents: shared-dir agents (Warp/Zed/Kimi/…) light up the universal target", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".warp"), { recursive: true }); mkdirSync(join(home, ".config", "zed"), { recursive: true }); @@ -167,7 +118,7 @@ test("agents: shared-dir agents (Warp/Zed/Kimi/…) light up the universal targe }); test("agents: Replit project marker in cwd lights up universal-xdg", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { const previousCwd = process.cwd(); process.chdir(home); try { @@ -183,7 +134,7 @@ test("agents: Replit project marker in cwd lights up universal-xdg", async () => }); test("agents: OpenClaw historical alias dirs are detected and link into the existing home", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { // Only the legacy .clawdbot home exists → links must land there, not in .openclaw mkdirSync(join(home, ".clawdbot"), { recursive: true }); const openclaw = detectInstalledAgents().find((agent) => agent.id === "openclaw"); @@ -197,7 +148,7 @@ test("agents: OpenClaw historical alias dirs are detected and link into the exis }); test("agents: VIBE_HOME/HERMES_HOME/AUTOHAND_HOME/GROK_HOME/DSH_HOME relocate their agents", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { const customDirs = { "mistral-vibe": join(home, "custom-vibe"), hermes: join(home, "custom-hermes"), @@ -224,7 +175,7 @@ test("agents: VIBE_HOME/HERMES_HOME/AUTOHAND_HOME/GROK_HOME/DSH_HOME relocate th }); test("agents: fan-out creates symlink to canonical; does not create dirs for uninstalled agents", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".claude"), { recursive: true }); const target = seedCanonicalSkill("demo"); @@ -243,7 +194,7 @@ test("agents: fan-out creates symlink to canonical; does not create dirs for uni }); test("agents: existing unmanaged dir is skipped; managed stale link is rebuilt", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".claude"), { recursive: true }); seedCanonicalSkill("demo"); @@ -265,7 +216,7 @@ test("agents: existing unmanaged dir is skipped; managed stale link is rebuilt", }); test("agents: unlink reclaims managed links, leaves foreign content untouched", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".claude"), { recursive: true }); mkdirSync(join(home, ".agents"), { recursive: true }); seedCanonicalSkill("demo"); @@ -284,7 +235,7 @@ test("agents: unlink reclaims managed links, leaves foreign content untouched", }); test("agents: planUnlink includes historical managed symlinks missing from lock", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".claude"), { recursive: true }); mkdirSync(join(home, ".agents"), { recursive: true }); const canonical = seedCanonicalSkill("demo"); @@ -309,7 +260,7 @@ test("agents: planUnlink includes historical managed symlinks missing from lock" }); test("agents: official config-dir env vars relocate detection and fan-out", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { const customClaude = join(home, "relocated-claude"); const customCodex = join(home, "relocated-codex"); mkdirSync(customClaude, { recursive: true }); @@ -339,7 +290,7 @@ test("agents: official config-dir env vars relocate detection and fan-out", asyn }); test("agents: Amp-style XDG config dir lights up the universal-xdg shared target", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".config", "amp"), { recursive: true }); const xdg = detectInstalledAgents().find((agent) => agent.id === "universal-xdg"); expect(xdg?.skillsDir).toBe(join(home, ".config", "agents", "skills")); @@ -352,7 +303,7 @@ test("agents: Amp-style XDG config dir lights up the universal-xdg shared target }); test("agents: foreign skill dir (contains SKILL.md) is replaced even without lock record", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".claude"), { recursive: true }); const canonical = seedCanonicalSkill("demo"); const copyPath = join(home, ".claude", "skills", "demo"); @@ -371,7 +322,7 @@ test("agents: foreign skill dir (contains SKILL.md) is replaced even without loc }); test("agents: foreign non-skill dir (no SKILL.md) stays skipped", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".claude"), { recursive: true }); seedCanonicalSkill("demo"); const foreignPath = join(home, ".claude", "skills", "demo"); @@ -388,7 +339,7 @@ test("agents: foreign non-skill dir (no SKILL.md) stays skipped", async () => { }); test("agents: recorded copy-fallback artifact is replaced with symlink", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".claude"), { recursive: true }); const canonical = seedCanonicalSkill("demo"); const copyPath = join(home, ".claude", "skills", "demo"); @@ -412,7 +363,7 @@ test("agents: recorded copy-fallback artifact is replaced with symlink", async ( }); test("agents: recorded plain file (not a copy dir) is never replaced", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".claude", "skills"), { recursive: true }); seedCanonicalSkill("demo"); const filePath = join(home, ".claude", "skills", "demo"); @@ -426,7 +377,7 @@ test("agents: recorded plain file (not a copy dir) is never replaced", async () }); test("agents: unlink removes recorded copy-fallback directories", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".claude"), { recursive: true }); seedCanonicalSkill("demo"); const copyPath = join(home, ".claude", "skills", "demo"); @@ -440,7 +391,7 @@ test("agents: unlink removes recorded copy-fallback directories", async () => { }); test("fanout: recorded path of an unvisited agent stays in the ledger", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".claude"), { recursive: true }); seedCanonicalSkill("demo"); // Simulate a copy artifact left by an agent that is no longer detected (e.g. uninstalled @@ -457,7 +408,7 @@ test("fanout: recorded path of an unvisited agent stays in the ledger", async () }); test("fanout: recorded path confirmed foreign this run is dropped from the ledger", async () => { - await inFakeHome(async (home) => { + await withIsolatedHome(async (home) => { mkdirSync(join(home, ".claude", "skills"), { recursive: true }); seedCanonicalSkill("demo"); // User replaced our artifact with their own plain file → scanned, skipped as unmanaged; diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 9e9797de5..83141debc 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -7,10 +7,12 @@ "exports": { "./gating": "./src/gating.ts", "./runner": "./src/run-subprocess.ts", + "./in-process": "./src/run-in-process.ts", "./output": "./src/output.ts", "./global-setup": "./src/global-setup.ts", "./monorepo-root": "./src/monorepo-root.ts", - "./registry-smoke": "./src/registry-smoke.ts" + "./registry-smoke": "./src/registry-smoke.ts", + "./isolation": "./src/isolation.ts" }, "dependencies": { "bailian-cli-core": "workspace:*" diff --git a/packages/e2e/src/gating.ts b/packages/e2e/src/gating.ts index c70dadcec..4624dd7d2 100644 --- a/packages/e2e/src/gating.ts +++ b/packages/e2e/src/gating.ts @@ -5,16 +5,10 @@ export function isBailianE2EEnabled(): boolean { return process.env.BAILIAN_E2E === "1"; } -/** 可调 DashScope 的 API Key:环境变量优先,否则读 ~/.bailian/config.json */ +/** 可调 DashScope 的 API Key。只认 `.env` / 进程环境里的 `DASHSCOPE_API_KEY`。 */ export function isDashScopeE2EReady(): boolean { if (!isBailianE2EEnabled()) return false; - if (process.env.DASHSCOPE_API_KEY?.trim()) return true; - try { - const config = buildSources({}).file; - return typeof config.api_key === "string" && config.api_key.length > 0; - } catch { - return false; - } + return !!process.env.DASHSCOPE_API_KEY?.trim(); } /** Console-gateway 命令 E2E 就绪检查 */ diff --git a/packages/e2e/src/global-setup.ts b/packages/e2e/src/global-setup.ts index 005cf3a8a..b369eb5fa 100644 --- a/packages/e2e/src/global-setup.ts +++ b/packages/e2e/src/global-setup.ts @@ -1,15 +1,21 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs"; import { join } from "path"; import { parseEnv } from "util"; +import { ISO_ROOT_KEY } from "./iso-root-keys.ts"; +import { cleanupIsolationSessionRootAt, createIsolationSessionRoot } from "./isolation.ts"; import { monorepoRoot } from "./monorepo-root.ts"; -/** Vitest globalSetup 写入的会话标记文件名 */ -export const E2E_RUN_SESSION_FILENAME = ".e2e-run-session"; +/** Marker filename written by Vitest globalSetup for the E2E output session id. */ +export const RUN_SESSION_FILE = ".e2e-run-session"; + +type GlobalSetupProvide = (key: string, value: string) => void; /** - * Vitest 在所有 worker 启动前执行一次:加载 `.env` 并写入共享 E2E 输出会话 id。 + * Runs once before Vitest workers: load `.env`, write the shared E2E output session id, + * and create this run's isolation parent dir (provide → setupFiles → BL_ISO_ROOT). + * Teardown deletes only the closed-over path; workers never own cleanup. */ -export default function vitestGlobalSetup(): () => void { +export default function vitestGlobalSetup(context?: { provide?: GlobalSetupProvide }): () => void { const root = monorepoRoot(); const rootEnv = join(root, ".env"); if (existsSync(rootEnv)) { @@ -53,13 +59,19 @@ BAILIAN_E2E_CHAT_AGENT_ID= const runId = `e2e-run-${dateStr} ${timeStr}`; const outDir = join(root, "test", "output"); mkdirSync(outDir, { recursive: true }); - const marker = join(outDir, E2E_RUN_SESSION_FILENAME); + const marker = join(outDir, RUN_SESSION_FILE); writeFileSync(marker, `${runId}\n`, "utf8"); + + // Close over this run's root; teardown deletes only this path. + const isolationSessionRoot = createIsolationSessionRoot(); + context?.provide?.(ISO_ROOT_KEY, isolationSessionRoot); + return () => { + cleanupIsolationSessionRootAt(isolationSessionRoot); try { unlinkSync(marker); } catch { - /* 忽略 */ + /* ignore */ } }; } diff --git a/packages/e2e/src/iso-root-keys.ts b/packages/e2e/src/iso-root-keys.ts new file mode 100644 index 000000000..4d99b00b8 --- /dev/null +++ b/packages/e2e/src/iso-root-keys.ts @@ -0,0 +1,10 @@ +/** + * Lightweight keys for passing the isolation session root from globalSetup to workers. + * setupFiles must only import this file — never isolation/core — so vi.mock is not broken. + */ + +/** Env var that carries this run's isolation session root. */ +export const ISO_ROOT_ENV = "BL_ISO_ROOT"; + +/** Vitest provide/inject key for the same path. */ +export const ISO_ROOT_KEY = "isoRoot"; diff --git a/packages/e2e/src/iso-root-setup.ts b/packages/e2e/src/iso-root-setup.ts new file mode 100644 index 000000000..d0223cd87 --- /dev/null +++ b/packages/e2e/src/iso-root-setup.ts @@ -0,0 +1,16 @@ +/** + * Vitest setupFiles: copy provided isolation parent into BL_ISO_ROOT. + * Depends only on iso-root-keys (not isolation/core). + */ +import { inject } from "vite-plus/test"; +import { ISO_ROOT_ENV, ISO_ROOT_KEY } from "./iso-root-keys.ts"; + +try { + // Cast: ProvidedContext augmentation is unreliable under this package's typecheck. + const sessionRoot = (inject as (key: string) => unknown)(ISO_ROOT_KEY); + if (typeof sessionRoot === "string" && sessionRoot.length > 0) { + process.env[ISO_ROOT_ENV] = sessionRoot; + } +} catch { + // No provide: leave BL_ISO_ROOT unset; getIsolationSessionRoot falls back to os.tmpdir(). +} diff --git a/packages/e2e/src/isolation.ts b/packages/e2e/src/isolation.ts new file mode 100644 index 000000000..c4cdd746c --- /dev/null +++ b/packages/e2e/src/isolation.ts @@ -0,0 +1,307 @@ +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AGENT_ENV_KEYS, + SKIP_HOST_AGENTS_ENV, + buildSources, + type ConfigFile, +} from "bailian-cli-core"; +import { ISO_ROOT_ENV, ISO_ROOT_KEY } from "./iso-root-keys.ts"; + +export { ISO_ROOT_ENV, ISO_ROOT_KEY }; + +/** + * Console fields copied into isolated config when seedCredentials is on. + * DashScope / OpenAPI credentials stay in `.env` / process env. + */ +const SEED_KEYS = [ + "access_token", + "workspace_id", + "console_site", + "console_region", + "console_switch_agent", +] as const satisfies readonly (keyof ConfigFile)[]; + +/** Isolation-critical keys: ignore undefined/empty so isolation is not undone. */ +const CRITICAL_ENVS = new Set([ + "HOME", + "USERPROFILE", + "BAILIAN_CONFIG_DIR", + "XDG_CONFIG_HOME", + SKIP_HOST_AGENTS_ENV, +]); + +/** Create this run's isolation parent (globalSetup once). Teardown must delete the returned path. */ +export function createIsolationSessionRoot(): string { + const sessionRoot = mkdtempSync(join(tmpdir(), "bl-e2e-session-")); + process.env[ISO_ROOT_ENV] = sessionRoot; + return sessionRoot; +} + +/** Temp parent: `BL_ISO_ROOT` from globalSetup, else `os.tmpdir()`. */ +export function getIsolationSessionRoot(): string { + const fromEnv = process.env[ISO_ROOT_ENV]?.trim(); + if (fromEnv && existsSync(fromEnv)) return fromEnv; + return tmpdir(); +} + +/** Delete the given session root (idempotent). Teardown must pass its closed-over path. */ +export function cleanupIsolationSessionRootAt(sessionRoot: string): void { + if (!sessionRoot) return; + if (process.env[ISO_ROOT_ENV] === sessionRoot) { + delete process.env[ISO_ROOT_ENV]; + } + rmSync(sessionRoot, { recursive: true, force: true }); +} + +function mkIsolationTemp(prefix: string): string { + return mkdtempSync(join(getIsolationSessionRoot(), prefix)); +} + +export { AGENT_ENV_KEYS, SKIP_HOST_AGENTS_ENV }; + +export interface IsolatedHomeHandle { + /** Fake HOME root */ + home: string; + /** Matching BAILIAN_CONFIG_DIR */ + configDir: string; + /** Restore process.env and remove the temp directory */ + restore: () => void; +} + +export interface WithIsolatedHomeOptions { + /** + * When true, copy console fields (e.g. access_token) from the host config. + * Default false. DashScope / OpenAPI credentials stay in env. + */ + seedCredentials?: boolean; +} + +/** Read console fields from the host config (call before changing BAILIAN_CONFIG_DIR). */ +function readConsoleSeed(): Partial { + try { + const file = buildSources({}).file; + const seed: Partial = {}; + for (const key of SEED_KEYS) { + const value = file[key]; + if (value !== undefined) { + (seed as Record)[key] = value; + } + } + return seed; + } catch { + return {}; + } +} + +/** Write isolated config: force en-US, no output preference; optionally seed console fields. */ +function writeIsolatedConfig(configDir: string, seedCredentials: boolean): void { + mkdirSync(configDir, { recursive: true }); + const body: ConfigFile = { + language: "en-US", + ...(seedCredentials ? readConsoleSeed() : {}), + }; + writeFileSync(join(configDir, "config.json"), `${JSON.stringify(body, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); +} + +function nonEmptyEnv(value: string | undefined): string | undefined { + if (typeof value !== "string" || value.length === 0) return undefined; + return value; +} + +/** + * Normalize caller overrides: + * - Critical keys: drop undefined/empty so HOME/config isolation is not undone + * - Other keys: keep undefined as "delete inherited value" (e.g. clear DASHSCOPE_API_KEY) + * - Other keys: keep empty strings as-is + */ +function sanitizeOverrides(overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const cleaned: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(overrides)) { + if (CRITICAL_ENVS.has(key)) { + if (value === undefined || value.length === 0) continue; + cleaned[key] = value; + continue; + } + cleaned[key] = value; + } + return cleaned; +} + +/** Apply overrides; undefined deletes the key so children do not inherit it. */ +function assignEnvOverrides(env: NodeJS.ProcessEnv, overrides: NodeJS.ProcessEnv): void { + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) delete env[key]; + else env[key] = value; + } +} + +/** + * Switch this process to a temp HOME/config and disable host Agent detect paths. + * Shared by skills/advisor/agent-writer unit tests; do not invent local inFakeHome helpers. + */ +export function enterIsolatedHome(options: WithIsolatedHomeOptions = {}): IsolatedHomeHandle { + const seedCredentials = options.seedCredentials === true; + const saved: Record = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + BAILIAN_CONFIG_DIR: process.env.BAILIAN_CONFIG_DIR, + [SKIP_HOST_AGENTS_ENV]: process.env[SKIP_HOST_AGENTS_ENV], + }; + for (const key of AGENT_ENV_KEYS) { + saved[key] = process.env[key]; + } + + const root = mkIsolationTemp("home-"); + mkdirSync(join(root, "home"), { recursive: true }); + // realpath: align with process.cwd() /private/var prefix on macOS + const home = realpathSync(join(root, "home")); + const configDir = join(home, ".bailian"); + writeIsolatedConfig(configDir, seedCredentials); + + process.env.HOME = home; + process.env.USERPROFILE = home; + process.env.XDG_CONFIG_HOME = join(home, ".config"); + process.env.BAILIAN_CONFIG_DIR = configDir; + process.env[SKIP_HOST_AGENTS_ENV] = "1"; + for (const key of AGENT_ENV_KEYS) { + delete process.env[key]; + } + + return { + home, + configDir, + restore: () => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +/** Async helper with automatic restore. */ +export async function withIsolatedHome( + fn: (home: string, configDir: string) => Promise, + options?: WithIsolatedHomeOptions, +): Promise { + const handle = enterIsolatedHome(options); + try { + await fn(handle.home, handle.configDir); + } finally { + handle.restore(); + } +} + +/** Sync helper with automatic restore. */ +export function withIsolatedHomeSync( + fn: (home: string, configDir: string) => void, + options?: WithIsolatedHomeOptions, +): void { + const handle = enterIsolatedHome(options); + try { + fn(handle.home, handle.configDir); + } finally { + handle.restore(); + } +} + +export interface BuildIsolatedCliEnvOptions { + /** + * Highest-priority env overrides. + * Non-empty `BAILIAN_CONFIG_DIR` is reused (no new isolated config). + * Critical keys: undefined/empty are ignored so isolation stays intact; + * other keys: undefined deletes inherited credentials from the child env. + */ + overrides?: NodeJS.ProcessEnv; + /** + * When true, copy console fields from the host config into the isolated config. + * Default false. DashScope / OpenAPI credentials stay in env. + */ + seedCredentials?: boolean; +} + +export interface IsolatedCliEnv { + env: NodeJS.ProcessEnv; + /** Remove temp dirs created by this call; caller-owned paths are left alone. */ + dispose: () => void; +} + +/** + * Build an isolated env for a CLI child process. + * Uses the caller's HOME or a temp HOME; rewrites XDG/Agent overrides under that HOME; + * disables host detect dirs. When `BAILIAN_CONFIG_DIR` is omitted: + * - owned/empty HOME → write `$HOME/.bailian` + * - caller HOME already has config.json → never overwrite; use an owned temp config dir + * `DASHSCOPE_API_KEY` and similar env vars are kept from `process.env`. + */ +export function buildIsolatedCliEnv(options: BuildIsolatedCliEnvOptions = {}): IsolatedCliEnv { + const overrides = sanitizeOverrides(options.overrides ?? {}); + const seedCredentials = options.seedCredentials === true; + const env: NodeJS.ProcessEnv = { ...process.env }; + + for (const key of AGENT_ENV_KEYS) { + if (!(key in overrides)) delete env[key]; + } + + const explicitConfigDir = nonEmptyEnv(overrides.BAILIAN_CONFIG_DIR); + const explicitHome = nonEmptyEnv(overrides.HOME) ?? nonEmptyEnv(overrides.USERPROFILE); + + let createdHomeRoot: string | undefined; + let createdConfigRoot: string | undefined; + let home: string; + if (explicitHome) { + home = explicitHome; + mkdirSync(home, { recursive: true }); + } else { + createdHomeRoot = mkIsolationTemp("iso-"); + mkdirSync(join(createdHomeRoot, "home"), { recursive: true }); + home = realpathSync(join(createdHomeRoot, "home")); + } + + env.HOME = home; + env.USERPROFILE = home; + if (!("XDG_CONFIG_HOME" in overrides)) { + env.XDG_CONFIG_HOME = join(home, ".config"); + } + + if (!explicitConfigDir) { + const homeConfigDir = join(home, ".bailian"); + const homeConfigFile = join(homeConfigDir, "config.json"); + const ownsHome = createdHomeRoot !== undefined; + if (ownsHome || !existsSync(homeConfigFile)) { + writeIsolatedConfig(homeConfigDir, seedCredentials); + env.BAILIAN_CONFIG_DIR = homeConfigDir; + } else { + // Caller HOME already has config: do not overwrite; use an owned temp dir + createdConfigRoot = mkIsolationTemp("cfg-"); + writeIsolatedConfig(createdConfigRoot, seedCredentials); + env.BAILIAN_CONFIG_DIR = createdConfigRoot; + } + } + + if (!(SKIP_HOST_AGENTS_ENV in overrides)) { + env[SKIP_HOST_AGENTS_ENV] = "1"; + } + + assignEnvOverrides(env, overrides); + return { + env, + dispose: () => { + if (createdHomeRoot) { + rmSync(createdHomeRoot, { recursive: true, force: true }); + createdHomeRoot = undefined; + } + if (createdConfigRoot) { + rmSync(createdConfigRoot, { recursive: true, force: true }); + createdConfigRoot = undefined; + } + }, + }; +} diff --git a/packages/e2e/src/output.ts b/packages/e2e/src/output.ts index 385299d86..9ed8e3e1e 100644 --- a/packages/e2e/src/output.ts +++ b/packages/e2e/src/output.ts @@ -1,7 +1,7 @@ import { mkdirSync, readFileSync } from "fs"; import { basename, join } from "path"; import { fileURLToPath } from "url"; -import { E2E_RUN_SESSION_FILENAME } from "./global-setup.ts"; +import { RUN_SESSION_FILE } from "./global-setup.ts"; import { monorepoRoot } from "./monorepo-root.ts"; import type { RunCliResult } from "./run-subprocess.ts"; @@ -9,7 +9,7 @@ let e2eOutputSessionId: string | undefined; function readE2eRunSessionFromOutputDir(): string | undefined { try { - const p = join(monorepoRoot(), "test", "output", E2E_RUN_SESSION_FILENAME); + const p = join(monorepoRoot(), "test", "output", RUN_SESSION_FILE); const t = readFileSync(p, "utf8").trim(); return t.length > 0 ? t : undefined; } catch { diff --git a/packages/e2e/src/run-in-process.ts b/packages/e2e/src/run-in-process.ts new file mode 100644 index 000000000..82d58e6b1 --- /dev/null +++ b/packages/e2e/src/run-in-process.ts @@ -0,0 +1,134 @@ +/* eslint-disable typescript/unbound-method -- Capture and restore method identity; never call the saved methods unbound. */ +import type { EventEmitter } from "node:events"; +import type { RunCliResult } from "./run-subprocess.ts"; + +let captureQueue: Promise = Promise.resolve(); + +interface CaptureOptions { + cwd?: string; + /** Full child env (execFile-style replace). Omit to inherit process.env. */ + env?: NodeJS.ProcessEnv; + entrypoint?: string; +} + +class CapturedExit extends Error {} + +function restoreListeners( + emitter: EventEmitter, + event: string, + listeners: ReturnType, +): void { + emitter.removeAllListeners(event); + for (const listener of listeners) emitter.on(event, listener); +} + +function replaceProcessEnv(next: NodeJS.ProcessEnv): void { + for (const name of Object.keys(process.env)) delete process.env[name]; + for (const [name, value] of Object.entries(next)) { + if (value !== undefined) process.env[name] = value; + } +} + +async function capture( + run: () => void | Promise, + args: string[], + options: CaptureOptions, +): Promise { + const originalCwd = process.cwd(); + const originalArgv = process.argv; + const originalEnvironment = { ...process.env }; + const originalExit = process.exit; + const originalExitCode = process.exitCode; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + const interruptListeners = process.rawListeners("SIGINT"); + const errorListeners = process.stdout.rawListeners("error"); + const streams = [process.stdin, process.stdout, process.stderr]; + const terminalProperties = streams.flatMap((stream) => + ["isTTY", "columns", "rows"].map((property) => ({ + stream, + property, + descriptor: Object.getOwnPropertyDescriptor(stream, property), + })), + ); + const exitSignal = new CapturedExit("Captured CLI exit"); + let stdout = ""; + let stderr = ""; + let exitCode: number | undefined; + + function writer(destination: "stdout" | "stderr"): typeof process.stdout.write { + return (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error) => void), + callback?: (error?: Error) => void, + ) => { + if (exitCode === undefined) { + const text = + typeof chunk === "string" + ? chunk + : Buffer.from(chunk).toString( + typeof encodingOrCallback === "string" ? encodingOrCallback : undefined, + ); + if (destination === "stdout") stdout += text; + else stderr += text; + } + const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + if (done) queueMicrotask(() => done()); + return true; + }) as typeof process.stdout.write; + } + + try { + if (options.cwd) process.chdir(options.cwd); + if (options.env) replaceProcessEnv(options.env); + Object.assign(process.env, { NODE_NO_WARNINGS: "1", DO_NOT_TRACK: "1" }); + process.argv = [process.execPath, options.entrypoint ?? "commands-e2e", ...args]; + process.exitCode = 0; + for (const { stream, property } of terminalProperties) + Object.defineProperty(stream, property, { + configurable: true, + writable: true, + value: property === "isTTY" ? false : undefined, + }); + process.stdout.write = writer("stdout"); + process.stderr.write = writer("stderr"); + process.exit = ((code?: number | string | null) => { + exitCode ??= Number(code ?? process.exitCode ?? 0); + throw exitSignal; + }) as typeof process.exit; + try { + await run(); + } catch (error) { + if (error !== exitSignal) throw error; + } + return { stdout, stderr, exitCode: exitCode ?? Number(process.exitCode ?? 0) }; + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.exit = originalExit; + process.exitCode = originalExitCode; + process.argv = originalArgv; + replaceProcessEnv(originalEnvironment); + for (const { stream, property, descriptor } of terminalProperties) { + if (descriptor) Object.defineProperty(stream, property, descriptor); + else Reflect.deleteProperty(stream, property); + } + restoreListeners(process, "SIGINT", interruptListeners); + restoreListeners(process.stdout, "error", errorListeners); + if (process.cwd() !== originalCwd) process.chdir(originalCwd); + } +} + +/** Explicitly opted-in offline CLI cases; captures are serialized within a worker. */ +export function runInProcess( + run: () => void | Promise, + args: string[], + options: CaptureOptions = {}, +): Promise { + const pending = captureQueue.then(() => capture(run, args, options)); + captureQueue = pending.then( + () => undefined, + () => undefined, + ); + return pending; +} diff --git a/packages/e2e/src/run-subprocess.ts b/packages/e2e/src/run-subprocess.ts index ca5eced8c..13a213875 100644 --- a/packages/e2e/src/run-subprocess.ts +++ b/packages/e2e/src/run-subprocess.ts @@ -1,6 +1,7 @@ import { execFile } from "child_process"; import { join } from "path"; import { promisify } from "util"; +import { buildIsolatedCliEnv } from "./isolation.ts"; import { monorepoRoot } from "./monorepo-root.ts"; const execFileAsync = promisify(execFile); @@ -12,19 +13,35 @@ export interface RunCliResult { exitCode: number; } -/** monorepo 根目录下的 tsx 可执行文件 */ +export interface RunNodeMainOptions { + cwd: string; + env?: NodeJS.ProcessEnv; + /** Default true. Set false only when the case must inherit the host `~/.bailian`. */ + isolate?: boolean; + /** Opt-in: copy host console access_token into isolated config. */ + seedCredentials?: boolean; +} + export function resolveTsxBin(): string { const root = monorepoRoot(); const name = process.platform === "win32" ? "tsx.cmd" : "tsx"; return join(root, "node_modules", ".bin", name); } -/** 子进程通过 Node 的 tsx loader 执行指定 main.ts 入口,不启动 tsx CLI 的 IPC server。 */ +/** Run main.ts via Node's tsx loader (no tsx CLI IPC server). */ export async function runNodeMain( mainTs: string, args: string[], - options: { cwd: string; env?: NodeJS.ProcessEnv } = { cwd: process.cwd() }, + options: RunNodeMainOptions = { cwd: process.cwd() }, ): Promise { + const isolate = options.isolate !== false; + const isolated = isolate + ? buildIsolatedCliEnv({ + overrides: options.env, + seedCredentials: options.seedCredentials, + }) + : undefined; + const env = isolated?.env ?? { ...process.env, ...options.env }; try { const { stdout, stderr } = await execFileAsync( process.execPath, @@ -34,24 +51,25 @@ export async function runNodeMain( encoding: "utf8", maxBuffer: 32 * 1024 * 1024, env: { - ...process.env, + ...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 { + } catch (caught: unknown) { + const error = caught as { stdout?: string; stderr?: string; code?: number; }; return { - stdout: e.stdout ?? "", - stderr: e.stderr ?? "", - exitCode: typeof e.code === "number" ? e.code : 1, + stdout: error.stdout ?? "", + stderr: error.stderr ?? "", + exitCode: typeof error.code === "number" ? error.code : 1, }; + } finally { + isolated?.dispose(); } } diff --git a/packages/e2e/tests/fixtures/echo-argv.ts b/packages/e2e/tests/fixtures/echo-argv.ts index 2bde4f17e..673edb390 100644 --- a/packages/e2e/tests/fixtures/echo-argv.ts +++ b/packages/e2e/tests/fixtures/echo-argv.ts @@ -2,6 +2,7 @@ interface FixtureOutput { args: string[]; execArgv: string[]; marker: string | null; + configDir: string | null; } if (process.argv[2] === "--fail") { @@ -12,6 +13,7 @@ if (process.argv[2] === "--fail") { args: process.argv.slice(2), execArgv: process.execArgv, marker: process.env.RUNNER_FIXTURE_MARKER ?? null, + configDir: process.env.BAILIAN_CONFIG_DIR ?? null, }; process.stdout.write(`${JSON.stringify(output)}\n`); } diff --git a/packages/e2e/tests/isolation.test.ts b/packages/e2e/tests/isolation.test.ts new file mode 100644 index 000000000..43dcea854 --- /dev/null +++ b/packages/e2e/tests/isolation.test.ts @@ -0,0 +1,352 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + detectInstalledAgents, + isHostSystemDetectDir, + SKIP_HOST_AGENTS_ENV, +} from "bailian-cli-core"; +import { expect, test } from "vite-plus/test"; +import { + buildIsolatedCliEnv, + cleanupIsolationSessionRootAt, + createIsolationSessionRoot, + enterIsolatedHome, + getIsolationSessionRoot, + withIsolatedHome, +} from "../src/isolation.ts"; + +test("isHostSystemDetectDir 识别 /etc 与 /Applications,排除 HOME 下路径", () => { + const handle = enterIsolatedHome(); + try { + expect(isHostSystemDetectDir("/etc/codex")).toBe(true); + expect(isHostSystemDetectDir("/Applications/ZCode.app")).toBe(true); + expect(isHostSystemDetectDir(join(handle.home, ".codex"))).toBe(false); + } finally { + handle.restore(); + } +}); + +test("withIsolatedHome 关闭系统 detectDirs,空 HOME 下不探测主机路径", async () => { + await withIsolatedHome(async (home) => { + expect(process.env[SKIP_HOST_AGENTS_ENV]).toBe("1"); + expect(process.env.HOME).toBe(home); + expect(process.env.BAILIAN_CONFIG_DIR).toBe(join(home, ".bailian")); + expect(process.env.CODEX_HOME).toBeUndefined(); + expect(process.env.CLAUDE_CONFIG_DIR).toBeUndefined(); + expect(process.env.LOCALAPPDATA).toBeUndefined(); + + expect(detectInstalledAgents()).toEqual([]); + + mkdirSync(join(home, ".claude"), { recursive: true }); + expect(detectInstalledAgents().map((agent) => agent.id)).toEqual(["claude-code"]); + }); +}); + +test("隔离开关打开时,HOME 外的绝对路径不探测", async () => { + const outside = mkdtempSync(join(tmpdir(), "bl-iso-outside-")); + try { + await withIsolatedHome(async (home) => { + process.env.CODEX_HOME = outside; + expect(isHostSystemDetectDir(outside)).toBe(true); + expect(detectInstalledAgents().some((agent) => agent.id === "codex")).toBe(false); + + delete process.env.CODEX_HOME; + mkdirSync(join(home, ".codex"), { recursive: true }); + expect(detectInstalledAgents().some((agent) => agent.id === "codex")).toBe(true); + }); + } finally { + rmSync(outside, { recursive: true, force: true }); + } +}); + +test("buildIsolatedCliEnv 默认只写 en-US,不拷贝凭证,dispose 删除临时目录", () => { + const probeRoot = mkdtempSync(join(tmpdir(), "bl-iso-seed-")); + const realConfigDir = join(probeRoot, "real-bailian"); + mkdirSync(realConfigDir, { recursive: true }); + writeFileSync( + join(realConfigDir, "config.json"), + JSON.stringify({ + language: "zh-CN", + output: "text", + api_key: "sk-seed-for-isolation", + access_key_secret: "sk-secret", + access_token: "console-seed-token", + }), + "utf8", + ); + + const previousConfigDir = process.env.BAILIAN_CONFIG_DIR; + const previousXdg = process.env.XDG_CONFIG_HOME; + const previousLocalAppData = process.env.LOCALAPPDATA; + process.env.BAILIAN_CONFIG_DIR = realConfigDir; + process.env.XDG_CONFIG_HOME = join(probeRoot, "host-xdg"); + process.env.LOCALAPPDATA = join(probeRoot, "host-local"); + const isolated = buildIsolatedCliEnv(); + try { + const env = isolated.env; + expect(env[SKIP_HOST_AGENTS_ENV]).toBe("1"); + expect(env.BAILIAN_CONFIG_DIR).toBeTruthy(); + expect(env.BAILIAN_CONFIG_DIR).not.toBe(realConfigDir); + expect(env.XDG_CONFIG_HOME).toBe(join(env.HOME!, ".config")); + expect(env.LOCALAPPDATA).toBeUndefined(); + const body = JSON.parse( + readFileSync(join(env.BAILIAN_CONFIG_DIR!, "config.json"), "utf8"), + ) as Record; + expect(body).toEqual({ language: "en-US" }); + } finally { + const configDir = isolated.env.BAILIAN_CONFIG_DIR!; + const home = isolated.env.HOME!; + isolated.dispose(); + expect(existsSync(configDir)).toBe(false); + expect(existsSync(home)).toBe(false); + if (previousConfigDir === undefined) delete process.env.BAILIAN_CONFIG_DIR; + else process.env.BAILIAN_CONFIG_DIR = previousConfigDir; + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousXdg; + if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA; + else process.env.LOCALAPPDATA = previousLocalAppData; + rmSync(probeRoot, { recursive: true, force: true }); + } +}); + +test("buildIsolatedCliEnv seedCredentials 只拷贝 Console 字段", () => { + const probeRoot = mkdtempSync(join(tmpdir(), "bl-iso-console-seed-")); + const realConfigDir = join(probeRoot, "real-bailian"); + mkdirSync(realConfigDir, { recursive: true }); + writeFileSync( + join(realConfigDir, "config.json"), + JSON.stringify({ + language: "zh-CN", + output: "text", + api_key: "sk-seed-for-isolation", + access_key_secret: "sk-secret", + access_token: "console-seed-token", + console_region: "cn-beijing", + }), + "utf8", + ); + + const previousConfigDir = process.env.BAILIAN_CONFIG_DIR; + process.env.BAILIAN_CONFIG_DIR = realConfigDir; + const isolated = buildIsolatedCliEnv({ seedCredentials: true }); + try { + const body = JSON.parse( + readFileSync(join(isolated.env.BAILIAN_CONFIG_DIR!, "config.json"), "utf8"), + ) as Record; + expect(body.language).toBe("en-US"); + expect(body.output).toBeUndefined(); + expect(body.api_key).toBeUndefined(); + expect(body.access_key_secret).toBeUndefined(); + expect(body.access_token).toBe("console-seed-token"); + expect(body.console_region).toBe("cn-beijing"); + } finally { + isolated.dispose(); + if (previousConfigDir === undefined) delete process.env.BAILIAN_CONFIG_DIR; + else process.env.BAILIAN_CONFIG_DIR = previousConfigDir; + rmSync(probeRoot, { recursive: true, force: true }); + } +}); + +test("buildIsolatedCliEnv 显式 HOME 时仍改写 XDG,配置落在该 HOME 下", () => { + const previousXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = join(tmpdir(), "host-xdg-should-not-leak"); + const home = mkdtempSync(join(tmpdir(), "bl-iso-explicit-")); + const isolated = buildIsolatedCliEnv({ + overrides: { BAILIAN_CONFIG_DIR: home, HOME: home }, + }); + try { + expect(isolated.env.BAILIAN_CONFIG_DIR).toBe(home); + expect(isolated.env.HOME).toBe(home); + expect(isolated.env.XDG_CONFIG_HOME).toBe(join(home, ".config")); + expect(isolated.env[SKIP_HOST_AGENTS_ENV]).toBe("1"); + isolated.dispose(); + expect(existsSync(home)).toBe(true); + } finally { + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousXdg; + rmSync(home, { recursive: true, force: true }); + } +}); + +test("只传空 HOME 时隔离 config 写在该 HOME/.bailian", () => { + const home = mkdtempSync(join(tmpdir(), "bl-iso-home-only-")); + const isolated = buildIsolatedCliEnv({ overrides: { HOME: home } }); + try { + expect(isolated.env.BAILIAN_CONFIG_DIR).toBe(join(home, ".bailian")); + expect(existsSync(join(home, ".bailian", "config.json"))).toBe(true); + isolated.dispose(); + expect(existsSync(join(home, ".bailian", "config.json"))).toBe(true); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("只传 HOME 且已有 config 时不覆盖,改用基建自有 config 目录", () => { + const home = mkdtempSync(join(tmpdir(), "bl-iso-home-preserve-")); + const existingDir = join(home, ".bailian"); + mkdirSync(existingDir, { recursive: true }); + const existingBody = { + language: "zh-CN", + api_key: "sk-keep-me", + active_config: "work", + }; + writeFileSync(join(existingDir, "config.json"), `${JSON.stringify(existingBody, null, 2)}\n`); + + const isolated = buildIsolatedCliEnv({ overrides: { HOME: home } }); + try { + expect(isolated.env.HOME).toBe(home); + expect(isolated.env.BAILIAN_CONFIG_DIR).not.toBe(existingDir); + expect(isolated.env.BAILIAN_CONFIG_DIR).toBeTruthy(); + const preserved = JSON.parse(readFileSync(join(existingDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(preserved).toEqual(existingBody); + const isolatedBody = JSON.parse( + readFileSync(join(isolated.env.BAILIAN_CONFIG_DIR!, "config.json"), "utf8"), + ) as Record; + expect(isolatedBody).toEqual({ language: "en-US" }); + } finally { + const ownedConfig = isolated.env.BAILIAN_CONFIG_DIR!; + isolated.dispose(); + expect(existsSync(ownedConfig)).toBe(false); + expect(existsSync(join(existingDir, "config.json"))).toBe(true); + rmSync(home, { recursive: true, force: true }); + } +}); + +test("undefined / 空串 overrides 不会取消 HOME 与 config 隔离", () => { + const isolated = buildIsolatedCliEnv({ + overrides: { + HOME: undefined, + USERPROFILE: "", + BAILIAN_CONFIG_DIR: undefined, + XDG_CONFIG_HOME: "", + [SKIP_HOST_AGENTS_ENV]: "", + DASHSCOPE_API_KEY: "", + }, + }); + try { + expect(isolated.env.HOME).toBeTruthy(); + expect(isolated.env.BAILIAN_CONFIG_DIR).toBeTruthy(); + expect(isolated.env.XDG_CONFIG_HOME).toBe(join(isolated.env.HOME!, ".config")); + expect(isolated.env[SKIP_HOST_AGENTS_ENV]).toBe("1"); + expect(isolated.env.DASHSCOPE_API_KEY).toBe(""); + expect(existsSync(join(isolated.env.BAILIAN_CONFIG_DIR!, "config.json"))).toBe(true); + } finally { + isolated.dispose(); + } +}); + +test("非关键键传 undefined 会删除继承自父进程的凭证", () => { + const previousKey = process.env.DASHSCOPE_API_KEY; + const previousToken = process.env.DASHSCOPE_ACCESS_TOKEN; + process.env.DASHSCOPE_API_KEY = "sk-should-not-leak"; + process.env.DASHSCOPE_ACCESS_TOKEN = "token-should-not-leak"; + const isolated = buildIsolatedCliEnv({ + overrides: { + DASHSCOPE_API_KEY: undefined, + DASHSCOPE_ACCESS_TOKEN: undefined, + }, + }); + try { + expect(isolated.env.HOME).toBeTruthy(); + expect(isolated.env.BAILIAN_CONFIG_DIR).toBeTruthy(); + expect(isolated.env.DASHSCOPE_API_KEY).toBeUndefined(); + expect(isolated.env.DASHSCOPE_ACCESS_TOKEN).toBeUndefined(); + expect("DASHSCOPE_API_KEY" in isolated.env).toBe(false); + expect("DASHSCOPE_ACCESS_TOKEN" in isolated.env).toBe(false); + } finally { + isolated.dispose(); + if (previousKey === undefined) delete process.env.DASHSCOPE_API_KEY; + else process.env.DASHSCOPE_API_KEY = previousKey; + if (previousToken === undefined) delete process.env.DASHSCOPE_ACCESS_TOKEN; + else process.env.DASHSCOPE_ACCESS_TOKEN = previousToken; + } +}); + +test("隔离子进程清理 OPENCLAW_CONFIG_PATH 与 ZCODE_HOME", () => { + const previousOpenclaw = process.env.OPENCLAW_CONFIG_PATH; + const previousZcode = process.env.ZCODE_HOME; + process.env.OPENCLAW_CONFIG_PATH = "/tmp/outside-openclaw.json"; + process.env.ZCODE_HOME = "/tmp/outside-zcode"; + const isolated = buildIsolatedCliEnv(); + try { + expect(isolated.env.OPENCLAW_CONFIG_PATH).toBeUndefined(); + expect(isolated.env.ZCODE_HOME).toBeUndefined(); + } finally { + isolated.dispose(); + if (previousOpenclaw === undefined) delete process.env.OPENCLAW_CONFIG_PATH; + else process.env.OPENCLAW_CONFIG_PATH = previousOpenclaw; + if (previousZcode === undefined) delete process.env.ZCODE_HOME; + else process.env.ZCODE_HOME = previousZcode; + } +}); + +test("session root:create 写入 BL_ISO_ROOT,临时目录挂在其下,cleanup 幂等", () => { + const previousEnv = process.env.BL_ISO_ROOT; + const sessionRoot = createIsolationSessionRoot(); + try { + expect(sessionRoot).toContain("bl-e2e-session-"); + expect(process.env.BL_ISO_ROOT).toBe(sessionRoot); + expect(getIsolationSessionRoot()).toBe(sessionRoot); + + const isolated = buildIsolatedCliEnv(); + try { + const homeReal = realpathSync(isolated.env.HOME!); + const sessionReal = realpathSync(sessionRoot); + expect(homeReal === sessionReal || homeReal.startsWith(sessionReal + "/")).toBe(true); + } finally { + isolated.dispose(); + } + } finally { + cleanupIsolationSessionRootAt(sessionRoot); + expect(existsSync(sessionRoot)).toBe(false); + cleanupIsolationSessionRootAt(sessionRoot); + expect(existsSync(sessionRoot)).toBe(false); + if (previousEnv === undefined) delete process.env.BL_ISO_ROOT; + else process.env.BL_ISO_ROOT = previousEnv; + } +}); + +test("无 BL_ISO_ROOT 时临时目录落在 os.tmpdir,不惰性创建 session", () => { + const previousEnv = process.env.BL_ISO_ROOT; + delete process.env.BL_ISO_ROOT; + try { + expect(getIsolationSessionRoot()).toBe(tmpdir()); + const isolated = buildIsolatedCliEnv(); + try { + const homeReal = realpathSync(isolated.env.HOME!); + const tmpReal = realpathSync(tmpdir()); + expect(homeReal === tmpReal || homeReal.startsWith(tmpReal + "/")).toBe(true); + expect(homeReal.includes("bl-e2e-session-")).toBe(false); + } finally { + isolated.dispose(); + } + } finally { + if (previousEnv === undefined) delete process.env.BL_ISO_ROOT; + else process.env.BL_ISO_ROOT = previousEnv; + } +}); + +test("cleanupIsolationSessionRootAt 只删指定 root,不误伤其它会话", () => { + const rootA = mkdtempSync(join(tmpdir(), "bl-e2e-session-a-")); + const rootB = mkdtempSync(join(tmpdir(), "bl-e2e-session-b-")); + try { + cleanupIsolationSessionRootAt(rootA); + cleanupIsolationSessionRootAt(rootA); + expect(existsSync(rootA)).toBe(false); + expect(existsSync(rootB)).toBe(true); + } finally { + cleanupIsolationSessionRootAt(rootB); + } +}); diff --git a/packages/e2e/tests/run-in-process.test.ts b/packages/e2e/tests/run-in-process.test.ts new file mode 100644 index 000000000..feeba4471 --- /dev/null +++ b/packages/e2e/tests/run-in-process.test.ts @@ -0,0 +1,176 @@ +/* eslint-disable typescript/unbound-method -- Capture and restore method identity; never call the saved methods unbound. */ +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vite-plus/test"; +import { buildIsolatedCliEnv } from "../src/isolation.ts"; +import { runInProcess } from "../src/run-in-process.ts"; + +const directories: string[] = []; +afterEach(() => { + for (const directory of directories.splice(0)) + rmSync(directory, { recursive: true, force: true }); +}); + +function makeDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), "bl-process-capture-")); + directories.push(directory); + return directory; +} + +test("captures stdout and stderr separately, including byte writes and callbacks", async () => { + let callbackCalled = false; + const result = await runInProcess(async () => { + process.stdout.write(Buffer.from("output"), () => { + callbackCalled = true; + }); + process.stderr.write("error\n"); + await Promise.resolve(); + }, []); + expect(result).toEqual({ stdout: "output", stderr: "error\n", exitCode: 0 }); + expect(callbackCalled).toBe(true); +}); + +test("preserves the first exit status and output even when a CLI error boundary catches exit", async () => { + const result = await runInProcess(async () => { + process.stderr.write("usage failure\n"); + try { + process.exit(2); + } catch { + process.stderr.write("unreachable error-boundary output"); + process.exit(1); + } + }, []); + expect(result).toEqual({ stdout: "", stderr: "usage failure\n", exitCode: 2 }); +}); + +test("uses child-style argv, cwd, environment and non-TTY streams, then restores them", async () => { + const directory = makeDirectory(); + const originalCwd = process.cwd(); + const originalArgv = process.argv; + const originalMarker = process.env.IN_PROCESS_TEST_MARKER; + const originalHostLeak = process.env.IN_PROCESS_HOST_LEAK; + const originalWrite = process.stdout.write; + const originalExit = process.exit; + process.env.IN_PROCESS_HOST_LEAK = "should-not-inherit"; + try { + const result = await runInProcess( + () => { + process.stdout.write( + JSON.stringify({ + cwd: process.cwd(), + args: process.argv.slice(2), + marker: process.env.IN_PROCESS_TEST_MARKER, + hostLeak: process.env.IN_PROCESS_HOST_LEAK ?? null, + tracked: process.env.DO_NOT_TRACK, + stdoutTty: process.stdout.isTTY, + stdinTty: process.stdin.isTTY, + }), + ); + process.env.CREATED_BY_CAPTURE_TEST = "temporary"; + }, + ["--output", "json"], + { cwd: directory, env: { IN_PROCESS_TEST_MARKER: "child" } }, + ); + expect(JSON.parse(result.stdout)).toMatchObject({ + cwd: realpathSync(directory), + args: ["--output", "json"], + marker: "child", + hostLeak: null, + tracked: "1", + stdoutTty: false, + stdinTty: false, + }); + expect(process.cwd()).toBe(originalCwd); + expect(process.argv).toBe(originalArgv); + expect(process.env.IN_PROCESS_TEST_MARKER).toBe(originalMarker); + expect(process.env.IN_PROCESS_HOST_LEAK).toBe("should-not-inherit"); + expect(process.env.CREATED_BY_CAPTURE_TEST).toBeUndefined(); + expect(process.stdout.write).toBe(originalWrite); + expect(process.exit).toBe(originalExit); + } finally { + if (originalHostLeak === undefined) delete process.env.IN_PROCESS_HOST_LEAK; + else process.env.IN_PROCESS_HOST_LEAK = originalHostLeak; + } +}); + +test("restores process state when the callback rejects unexpectedly", async () => { + const originalCwd = process.cwd(); + const originalWrite = process.stderr.write; + const originalExitCode = process.exitCode; + await expect( + runInProcess( + () => { + process.exitCode = 9; + throw new Error("fixture rejection"); + }, + [], + { cwd: makeDirectory() }, + ), + ).rejects.toThrow("fixture rejection"); + expect(process.cwd()).toBe(originalCwd); + expect(process.stderr.write).toBe(originalWrite); + expect(process.exitCode).toBe(originalExitCode); +}); + +test("buildIsolatedCliEnv + runInProcess clears host Agent override env", async () => { + const previousCodex = process.env.CODEX_HOME; + process.env.CODEX_HOME = "/tmp/host-codex-should-not-leak"; + const isolated = buildIsolatedCliEnv(); + try { + const result = await runInProcess( + () => { + process.stdout.write( + JSON.stringify({ + codex: process.env.CODEX_HOME ?? null, + skipHost: process.env.BL_SKIP_HOST_AGENTS ?? null, + configDir: process.env.BAILIAN_CONFIG_DIR ?? null, + }), + ); + }, + [], + { env: isolated.env }, + ); + const body = JSON.parse(result.stdout) as { + codex: string | null; + skipHost: string | null; + configDir: string | null; + }; + expect(body.codex).toBeNull(); + expect(body.skipHost).toBe("1"); + expect(body.configDir).toBeTruthy(); + expect(process.env.CODEX_HOME).toBe("/tmp/host-codex-should-not-leak"); + } finally { + isolated.dispose(); + if (previousCodex === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodex; + } +}); + +test("serializes overlapping captures without mixing output or environment", async () => { + const results = await Promise.all( + ["first", "second"].map((marker) => + runInProcess( + async () => { + process.stdout.write(process.env.IN_PROCESS_TEST_MARKER!); + await new Promise((resolve) => setTimeout(resolve, 5)); + process.stdout.write(process.env.IN_PROCESS_TEST_MARKER!); + }, + [], + { env: { IN_PROCESS_TEST_MARKER: marker } }, + ), + ), + ); + expect(results.map((result) => result.stdout)).toEqual(["firstfirst", "secondsecond"]); +}); + +test("removes CLI lifecycle listeners without disturbing existing listeners", async () => { + const originalInterruptListeners = process.rawListeners("SIGINT"); + const originalErrorListeners = process.stdout.rawListeners("error"); + await runInProcess(() => { + process.on("SIGINT", () => {}); + process.stdout.on("error", () => {}); + }, []); + expect(process.rawListeners("SIGINT")).toEqual(originalInterruptListeners); + expect(process.stdout.rawListeners("error")).toEqual(originalErrorListeners); +}); diff --git a/packages/e2e/tests/run-subprocess.test.ts b/packages/e2e/tests/run-subprocess.test.ts index daacbdb5a..311c1134f 100644 --- a/packages/e2e/tests/run-subprocess.test.ts +++ b/packages/e2e/tests/run-subprocess.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test } from "vite-plus/test"; @@ -18,9 +19,12 @@ test("runNodeMain 通过 Node 的 tsx loader 执行 TypeScript 入口", async () args: string[]; execArgv: string[]; marker: string | null; + configDir: string | null; }; expect(output.args).toEqual(["alpha", "beta"]); expect(output.marker).toBe("forwarded"); + expect(output.configDir).toBeTruthy(); + expect(existsSync(output.configDir!)).toBe(false); const importFlagIndex = output.execArgv.indexOf("--import"); expect(importFlagIndex).toBeGreaterThanOrEqual(0); expect(output.execArgv[importFlagIndex + 1]).toMatch(/tsx/); diff --git a/packages/e2e/vite.config.ts b/packages/e2e/vite.config.ts index 168a023f2..4c43b57bb 100644 --- a/packages/e2e/vite.config.ts +++ b/packages/e2e/vite.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ + test: { + globalSetup: "./src/global-setup.ts", + setupFiles: ["./src/iso-root-setup.ts"], + testTimeout: 60_000, + hookTimeout: 60_000, + }, lint: { options: { typeAware: true, diff --git a/packages/kscli/tests/e2e/helpers.ts b/packages/kscli/tests/e2e/helpers.ts index 65e56b377..257bbd206 100644 --- a/packages/kscli/tests/e2e/helpers.ts +++ b/packages/kscli/tests/e2e/helpers.ts @@ -1,5 +1,3 @@ -import { mkdtempSync } from "fs"; -import { tmpdir } from "os"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { parseStdoutJson } from "e2e/output"; @@ -27,16 +25,16 @@ export const kscliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), ". const mainTs = join(kscliPackageRoot, "src", "main.ts"); -/** 子进程执行 kscli */ +/** + * 子进程执行 kscli。 + * 默认走 e2e runner 的隔离环境(临时 HOME/config + en-US);无需再手建 BAILIAN_CONFIG_DIR。 + */ export async function runKscli( args: string[], envOverrides: NodeJS.ProcessEnv = {}, ): Promise { return runNodeMain(mainTs, args, { cwd: kscliPackageRoot, - env: { - BAILIAN_CONFIG_DIR: mkdtempSync(join(tmpdir(), "kscli-test-")), - ...envOverrides, - }, + env: envOverrides, }); } diff --git a/packages/kscli/vite.config.ts b/packages/kscli/vite.config.ts index f8c61f9b5..99a51a524 100644 --- a/packages/kscli/vite.config.ts +++ b/packages/kscli/vite.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ test: { globalSetup: "../e2e/src/global-setup.ts", + setupFiles: ["../e2e/src/iso-root-setup.ts"], testTimeout: 60_000, hookTimeout: 60_000, }, diff --git a/packages/runtime/vite.config.ts b/packages/runtime/vite.config.ts index 732788c3d..6a4f23076 100644 --- a/packages/runtime/vite.config.ts +++ b/packages/runtime/vite.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ test: { globalSetup: "../e2e/src/global-setup.ts", + setupFiles: ["../e2e/src/iso-root-setup.ts"], testTimeout: 60_000, hookTimeout: 60_000, }, diff --git a/vite.config.ts b/vite.config.ts index 38ce1c26e..c7dc1fd14 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -35,6 +35,7 @@ function restrictCommandCapabilities( export default defineConfig({ test: { globalSetup: "./packages/e2e/src/global-setup.ts", + setupFiles: ["./packages/e2e/src/iso-root-setup.ts"], testTimeout: 60_000, hookTimeout: 60_000, },