Skip to content

Commit a3eed8a

Browse files
committed
fix(e2e): isolate runCommandInProcess with full env snapshot
1 parent 17c269b commit a3eed8a

9 files changed

Lines changed: 125 additions & 89 deletions

File tree

‎docs/agents/cli-e2e-tests.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
113113

114114
- **禁止破坏真实用户配置**:`auth logout` 和 `config set` 默认只用 `--dry-run`;只有验证持久化契约时,才允许通过
115115
`BAILIAN_CONFIG_DIR` 指向每个用例独占的临时目录实际落盘,并必须在 `finally` 中清理;禁止写入或复用真实 `~/.bailian`
116-
- **默认测试隔离**:`runNodeMain` / `runCommandE2e` / `runCli` / `runKscli` 默认走 `e2e/isolation`(HOME 与 `XDG_CONFIG_HOME` 隔离、强制 `language: en-US`、关闭 HOME 外的系统/项目目录探测、清空 Agent 覆盖 env)。DashScope / OpenAPI 凭证继续走环境变量;Console live 用例用 `runConsoleCommandE2e` / `runConsoleCli` 带上 `access_token`。进程内单测用 `withIsolatedHome` / `enterIsolatedHome`,**禁止**各文件自写 `inFakeHome`。完整约定见 [test-isolation.md](test-isolation.md)。
116+
- **默认测试隔离**:`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)。
117117
- **不加 dry-run**:`dryRun` 在 `resolveFileUrl` / `resolveCredential` / 上传**之后**的命令(如 `image edit`、`speech recognize` 带 `--url`)
118118
- **`--list-voices` 等旁路**:先于 `--text` 校验的 flag,缺参用例勿带该 flag
119119
- 新增 required option → 至少一条缺参用例;改 dry-run 输出 → 更新对应断言

‎docs/agents/test-isolation.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616
│
1717
▼
1818
e2e/isolation(统一入口)
19-
├─ 进程内:withIsolatedHome / enterIsolatedHome
20-
└─ 子进程:runNodeMain → buildIsolatedCliEnv
19+
├─ 进程内单测:withIsolatedHome / enterIsolatedHome
20+
├─ 离线 in-process E2E:runCommandInProcess → buildIsolatedCliEnv → runInProcess(完整 env 快照)
21+
└─ 子进程 E2E:runNodeMain → buildIsolatedCliEnv
2122
│
2223
├─ 调用方 HOME,否则临时 HOME;XDG 始终改写到该 HOME
2324
├─ 未传 BAILIAN_CONFIG_DIR:空 HOME 写 $HOME/.bailian;已有 config 则用自有临时目录(不覆盖)
@@ -26,7 +27,7 @@
2627
├─ Console access_token 仅 seedCredentials: true 时拷贝
2728
├─ 清空 AGENT_ENV_KEYS(含 OPENCLAW / ZCODE / LOCALAPPDATA)
2829
├─ BL_SKIP_HOST_AGENTS=1
29-
└─ runNodeMain 结束时删除自己创建的临时目录
30+
└─ 结束后 dispose / restore 删除自己创建的临时目录
3031
3132
Session 父目录(仅扫尾归堆,不承载隔离语义):
3233
globalSetup createIsolationSessionRoot → provide → setupFiles 写 BL_ISO_ROOT
@@ -42,6 +43,7 @@
4243
- [ ] **commands / runtime 进程内单测**(agent writer 等):`import { withIsolatedHome, enterIsolatedHome } from "e2e/isolation"`
4344
- [ ] 禁止本地再写 `inFakeHome` / 直接 `process.env.HOME =`
4445
- [ ] **CLI 子进程 E2E**:走 `runCommandE2e` / `runCli` / `runKscli` → 底层 `runNodeMain` **默认已隔离**;用例只需在需要时传自己的 `BAILIAN_CONFIG_DIR` / `HOME`
46+
- [ ] **离线 in-process E2E**:走 `runCommandInProcess` → 同样 `buildIsolatedCliEnv`;`runInProcess` 的 `env` 是完整快照(非增量 merge)
4547
- [ ] 只传 `HOME` 且该 HOME 下**已有** `config.json` 时,基建**不会覆盖**,改用自有临时 `BAILIAN_CONFIG_DIR`;空 HOME 才写 `$HOME/.bailian`
4648
- [ ] 隔离关键键的 `undefined` / 空串会被忽略,不能拆掉隔离;其它键传 `undefined` 会删除继承凭证(如 `DASHSCOPE_API_KEY: undefined`)
4749
- [ ] **必须继承真实用户配置**的极少数用例:`runNodeMain(..., { isolate: false })`,并在注释里写明原因

‎packages/commands/tests/e2e/helpers.ts‎

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import {
1010
} from "e2e/output";
1111
import { captureRegistryHelp } from "e2e/registry-smoke";
1212
import { runNodeMain, type RunCliResult } from "e2e/runner";
13-
import { CommandRegistry, createCli, resolve } from "bailian-cli-runtime";
13+
import { buildIsolatedCliEnv } from "e2e/isolation";
1414
import { runInProcess } from "e2e/in-process";
15+
import { CommandRegistry, createCli, resolve } from "bailian-cli-runtime";
1516
import { loadRouteCommands } from "./harness/routes.ts";
1617
import { E2E_HARNESS_IDENTITY } from "./harness/identity.ts";
1718
import { withHarnessUpdateFixture } from "./harness/update-fixture.ts";
@@ -50,10 +51,8 @@ const e2eDir = dirname(fileURLToPath(import.meta.url));
5051
const harnessMainTs = join(e2eDir, "harness", "main.ts");
5152
const registryCache = new WeakMap<E2eRouteExports, Promise<CommandRegistry>>();
5253

53-
/** `packages/commands` 根目录 */
5454
export const commandsPackageRoot = join(e2eDir, "..", "..");
5555

56-
/** E2E fixtures 目录 */
5756
export const e2eFixturesDir = join(e2eDir, "fixtures");
5857

5958
function serializeRoutes(routes: E2eRouteExports): string {
@@ -73,7 +72,7 @@ function getCommandRegistry(routes: E2eRouteExports): Promise<CommandRegistry> {
7372
return registryPromise;
7473
}
7574

76-
/** 使用 topic 最小路由在当前 Vitest worker 中渲染命令 help,不启动 CLI 子进程。 */
75+
/** In-worker help via topic routes (no CLI subprocess). */
7776
export async function captureCommandHelp(
7877
routes: E2eRouteExports,
7978
commandPath: string[],
@@ -86,7 +85,7 @@ export async function captureCommandHelp(
8685
return captureRegistryHelp(registry, commandPath);
8786
}
8887

89-
/** 与 runCommandE2e 的结果结构兼容,但 help 在当前 Vitest worker 中渲染。 */
88+
/** Same result shape as runCommandE2e; help is rendered in-worker. */
9089
export async function runCommandHelp(
9190
routes: E2eRouteExports,
9291
args: string[],
@@ -99,14 +98,11 @@ export async function runCommandHelp(
9998
}
10099

101100
export interface CommandE2eRunOptions {
102-
/** 把本机 config 里的 console access_token 写入隔离配置。 */
101+
/** Copy host console access_token into the isolated config. */
103102
seedCredentials?: boolean;
104103
}
105104

106-
/**
107-
* 通过 harness 子进程执行命令 E2E。
108-
* `routes` 为本用例所需的最小 path → export 映射,不维护全量产品 map。
109-
*/
105+
/** Harness subprocess E2E; `routes` is the minimal path → export map for this topic. */
110106
export async function runCommandE2e(
111107
routes: E2eRouteExports,
112108
args: string[],
@@ -123,7 +119,7 @@ export async function runCommandE2e(
123119
});
124120
}
125121

126-
/** Console live 用例:隔离环境里带上本机 access_token。 */
122+
/** Console live: isolate and seed host access_token. */
127123
export async function runConsoleCommandE2e(
128124
routes: E2eRouteExports,
129125
args: string[],
@@ -132,7 +128,10 @@ export async function runConsoleCommandE2e(
132128
return runCommandE2e(routes, args, envOverrides, { seedCredentials: true });
133129
}
134130

135-
/** Opt-in for offline cases; process preloads and proxy configuration retain real subprocesses. */
131+
/**
132+
* Offline opt-in: real createCli in-process with e2e/isolation.
133+
* Falls back to a subprocess when NODE_OPTIONS or HTTP(S)_PROXY is set.
134+
*/
136135
export async function runCommandInProcess(
137136
routes: E2eRouteExports,
138137
args: string[],
@@ -145,12 +144,18 @@ export async function runCommandInProcess(
145144
environment.HTTPS_PROXY ||
146145
environment.http_proxy ||
147146
environment.https_proxy
148-
)
147+
) {
149148
return runCommandE2e(routes, args, envOverrides);
150-
const commands = await loadRouteCommands(routes);
151-
return runInProcess(
152-
() => withHarnessUpdateFixture(() => createCli(commands, E2E_HARNESS_IDENTITY).run(args)),
153-
args,
154-
{ cwd: commandsPackageRoot, env: envOverrides, entrypoint: harnessMainTs },
155-
);
149+
}
150+
const isolated = buildIsolatedCliEnv({ overrides: envOverrides });
151+
try {
152+
const commands = await loadRouteCommands(routes);
153+
return await runInProcess(
154+
() => withHarnessUpdateFixture(() => createCli(commands, E2E_HARNESS_IDENTITY).run(args)),
155+
args,
156+
{ cwd: commandsPackageRoot, env: isolated.env, entrypoint: harnessMainTs },
157+
);
158+
} finally {
159+
isolated.dispose();
160+
}
156161
}

‎packages/e2e/src/iso-root-setup.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
2-
* Vitest setupFiles:把 globalSetup provide 的本轮隔离父目录写入 BL_ISO_ROOT,
3-
* 供 mkIsolationTemp 挂载临时子目录。只依赖 iso-root-keys,不拉 isolation/core。
2+
* Vitest setupFiles: copy provided isolation parent into BL_ISO_ROOT.
3+
* Depends only on iso-root-keys (not isolation/core).
44
*/
55
import { inject } from "vite-plus/test";
66
import { ISO_ROOT_ENV, ISO_ROOT_KEY } from "./iso-root-keys.ts";
@@ -12,5 +12,5 @@ try {
1212
process.env[ISO_ROOT_ENV] = sessionRoot;
1313
}
1414
} catch {
15-
// 无 globalSetup/provide 时不写 BL_ISO_ROOT,getIsolationSessionRoot 回退到 os.tmpdir()。
15+
// No provide: leave BL_ISO_ROOT unset; getIsolationSessionRoot falls back to os.tmpdir().
1616
}

‎packages/e2e/src/isolation.ts‎

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,30 +32,21 @@ const CRITICAL_ENVS = new Set([
3232
SKIP_HOST_AGENTS_ENV,
3333
]);
3434

35-
/**
36-
* 本轮隔离父目录(仅供 Vitest globalSetup 调用一次)。
37-
* 返回值必须由 teardown 闭包捕获后删除;worker 通过 provide → BL_ISO_ROOT 复用,不宣称所有权。
38-
*/
35+
/** Create this run's isolation parent (globalSetup once). Teardown must delete the returned path. */
3936
export function createIsolationSessionRoot(): string {
4037
const sessionRoot = mkdtempSync(join(tmpdir(), "bl-e2e-session-"));
4138
process.env[ISO_ROOT_ENV] = sessionRoot;
4239
return sessionRoot;
4340
}
4441

45-
/**
46-
* 临时目录父路径:优先 globalSetup 注入的 `BL_ISO_ROOT`,否则 `os.tmpdir()`。
47-
* 不惰性创建 session、不挂信号、不维护进程所有权。
48-
*/
42+
/** Temp parent: `BL_ISO_ROOT` from globalSetup, else `os.tmpdir()`. */
4943
export function getIsolationSessionRoot(): string {
5044
const fromEnv = process.env[ISO_ROOT_ENV]?.trim();
5145
if (fromEnv && existsSync(fromEnv)) return fromEnv;
5246
return tmpdir();
5347
}
5448

55-
/**
56-
* 删除指定 session 根。幂等(force);只删传入路径。
57-
* globalSetup teardown 必须传入自己闭包里的 root。
58-
*/
49+
/** Delete the given session root (idempotent). Teardown must pass its closed-over path. */
5950
export function cleanupIsolationSessionRootAt(sessionRoot: string): void {
6051
if (!sessionRoot) return;
6152
if (process.env[ISO_ROOT_ENV] === sessionRoot) {
@@ -64,7 +55,6 @@ export function cleanupIsolationSessionRootAt(sessionRoot: string): void {
6455
rmSync(sessionRoot, { recursive: true, force: true });
6556
}
6657

67-
/** 在 session 根(或 os.tmpdir)下创建临时子目录。 */
6858
function mkIsolationTemp(prefix: string): string {
6959
return mkdtempSync(join(getIsolationSessionRoot(), prefix));
7060
}

‎packages/e2e/src/run-in-process.ts‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ let captureQueue: Promise<unknown> = Promise.resolve();
66

77
interface CaptureOptions {
88
cwd?: string;
9+
/** Full child env (execFile-style replace). Omit to inherit process.env. */
910
env?: NodeJS.ProcessEnv;
1011
entrypoint?: string;
1112
}
@@ -21,6 +22,13 @@ function restoreListeners(
2122
for (const listener of listeners) emitter.on(event, listener);
2223
}
2324

25+
function replaceProcessEnv(next: NodeJS.ProcessEnv): void {
26+
for (const name of Object.keys(process.env)) delete process.env[name];
27+
for (const [name, value] of Object.entries(next)) {
28+
if (value !== undefined) process.env[name] = value;
29+
}
30+
}
31+
2432
async function capture(
2533
run: () => void | Promise<void>,
2634
args: string[],
@@ -72,11 +80,8 @@ async function capture(
7280

7381
try {
7482
if (options.cwd) process.chdir(options.cwd);
83+
if (options.env) replaceProcessEnv(options.env);
7584
Object.assign(process.env, { NODE_NO_WARNINGS: "1", DO_NOT_TRACK: "1" });
76-
for (const [name, value] of Object.entries(options.env ?? {})) {
77-
if (value === undefined) delete process.env[name];
78-
else process.env[name] = value;
79-
}
8085
process.argv = [process.execPath, options.entrypoint ?? "commands-e2e", ...args];
8186
process.exitCode = 0;
8287
for (const { stream, property } of terminalProperties)
@@ -103,9 +108,7 @@ async function capture(
103108
process.exit = originalExit;
104109
process.exitCode = originalExitCode;
105110
process.argv = originalArgv;
106-
for (const name of Object.keys(process.env))
107-
if (!(name in originalEnvironment)) delete process.env[name];
108-
Object.assign(process.env, originalEnvironment);
111+
replaceProcessEnv(originalEnvironment);
109112
for (const { stream, property, descriptor } of terminalProperties) {
110113
if (descriptor) Object.defineProperty(stream, property, descriptor);
111114
else Reflect.deleteProperty(stream, property);

‎packages/e2e/src/run-subprocess.ts‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,19 @@ export interface RunCliResult {
1616
export interface RunNodeMainOptions {
1717
cwd: string;
1818
env?: NodeJS.ProcessEnv;
19-
/**
20-
* 默认 true:子进程使用隔离 HOME/config(强制 en-US、关闭系统 Agent 探测)。
21-
* 仅当用例必须完整继承宿主 `~/.bailian` 时设为 false。
22-
*/
19+
/** Default true. Set false only when the case must inherit the host `~/.bailian`. */
2320
isolate?: boolean;
24-
/**
25-
* 为 true 时把本机 config 里的 console access_token 写入隔离配置。
26-
* 默认 false。DashScope / OpenAPI 继续使用环境变量。
27-
*/
21+
/** Opt-in: copy host console access_token into isolated config. */
2822
seedCredentials?: boolean;
2923
}
3024

31-
/** monorepo 根目录下的 tsx 可执行文件 */
3225
export function resolveTsxBin(): string {
3326
const root = monorepoRoot();
3427
const name = process.platform === "win32" ? "tsx.cmd" : "tsx";
3528
return join(root, "node_modules", ".bin", name);
3629
}
3730

38-
/** 子进程通过 Node 的 tsx loader 执行指定 main.ts 入口,不启动 tsx CLI 的 IPC server。 */
31+
/** Run main.ts via Node's tsx loader (no tsx CLI IPC server). */
3932
export async function runNodeMain(
4033
mainTs: string,
4134
args: string[],

‎packages/e2e/tests/isolation.test.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ test("withIsolatedHome 关闭系统 detectDirs,空 HOME 下不探测主机路
4444
expect(process.env.CLAUDE_CONFIG_DIR).toBeUndefined();
4545
expect(process.env.LOCALAPPDATA).toBeUndefined();
4646

47-
// 未创建任何 agent 目录时,列表应为空(不受 /etc/codex 等主机路径影响)
4847
expect(detectInstalledAgents()).toEqual([]);
4948

5049
mkdirSync(join(home, ".claude"), { recursive: true });
@@ -241,7 +240,6 @@ test("undefined / 空串 overrides 不会取消 HOME 与 config 隔离", () => {
241240
expect(isolated.env.BAILIAN_CONFIG_DIR).toBeTruthy();
242241
expect(isolated.env.XDG_CONFIG_HOME).toBe(join(isolated.env.HOME!, ".config"));
243242
expect(isolated.env[SKIP_HOST_AGENTS_ENV]).toBe("1");
244-
// 非隔离关键键仍允许空串(测试故意清凭证)
245243
expect(isolated.env.DASHSCOPE_API_KEY).toBe("");
246244
expect(existsSync(join(isolated.env.BAILIAN_CONFIG_DIR!, "config.json"))).toBe(true);
247245
} finally {

0 commit comments

Comments
 (0)