|
1 | | -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| 1 | +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
2 | 2 | import { createServer } from "node:net"; |
3 | 3 | import { tmpdir } from "node:os"; |
4 | 4 | import { join } from "node:path"; |
@@ -57,6 +57,35 @@ function isolatedAgentsConfigEnv(): NodeJS.ProcessEnv { |
57 | 57 | return { AGENTS_CONFIG_PATH: join(tmpdir(), "bl-e2e-no-agents-config.json") }; |
58 | 58 | } |
59 | 59 |
|
| 60 | +/** |
| 61 | + * 在临时目录里搭一套非空 state 的项目:agents.yaml 复用单 provider fixture, |
| 62 | + * agents.state.json 预置一条已追踪资源 —— 非空 state 是触发 plan 默认 refresh |
| 63 | + * 路径的前提,用于验证 --dry-run 强制离线。目录纳入 tempDirs 自动清理。 |
| 64 | + */ |
| 65 | +function makeStatefulProject(): { configPath: string; statePath: string } { |
| 66 | + const dir = mkdtempSync(join(tmpdir(), "bl-managed-agent-dry-run-")); |
| 67 | + tempDirs.push(dir); |
| 68 | + const configPath = join(dir, "agents.yaml"); |
| 69 | + const statePath = join(dir, "agents.state.json"); |
| 70 | + writeFileSync(configPath, readFileSync(AGENTS_YAML, "utf8")); |
| 71 | + writeFileSync( |
| 72 | + statePath, |
| 73 | + `${JSON.stringify( |
| 74 | + { |
| 75 | + resources: [ |
| 76 | + { |
| 77 | + address: { provider: "bailian", type: "agent", name: "assistant" }, |
| 78 | + remote_id: "agent-e2e-dry-run", |
| 79 | + }, |
| 80 | + ], |
| 81 | + }, |
| 82 | + null, |
| 83 | + 2, |
| 84 | + )}\n`, |
| 85 | + ); |
| 86 | + return { configPath, statePath }; |
| 87 | +} |
| 88 | + |
60 | 89 | /** 分配一个刚释放的本地端口,连接必然 ECONNREFUSED,用于网络错误场景。 */ |
61 | 90 | async function closedPort(): Promise<number> { |
62 | 91 | const server = createServer(); |
@@ -215,3 +244,38 @@ describe("e2e: managed-agent 鉴权分层(离线命令免登录 / provider-awa |
215 | 244 | expect(stderr).toMatch(/ANTHROPIC_API_KEY/); |
216 | 245 | }); |
217 | 246 | }); |
| 247 | + |
| 248 | +describe("e2e: plan --dry-run 离线契约(不联网 / 不写 state / 免凭证)", () => { |
| 249 | + test("无任何凭证时 plan --dry-run 不报 AUTH,离线出 plan (0)", async () => { |
| 250 | + const env = makeConfigEnv({}); |
| 251 | + const { stderr, exitCode } = await runCommandE2e( |
| 252 | + ROUTES, |
| 253 | + [...planArgs(AGENTS_YAML), "--dry-run"], |
| 254 | + env, |
| 255 | + ); |
| 256 | + expect(exitCode, stderr).toBe(0); |
| 257 | + }); |
| 258 | + |
| 259 | + test("有凭证且 state 非空时,--dry-run 跳过 refresh:不发请求、state 文件不变;同环境不加 --dry-run 则证明会联网", async () => { |
| 260 | + const { configPath, statePath } = makeStatefulProject(); |
| 261 | + // base_url 指向必然 ECONNREFUSED 的本地端口:一旦 refresh 真发请求必现形。 |
| 262 | + // refresh 对 API 错误优雅降级(不影响退出码),因此用 stderr 的 |
| 263 | + // "Failed to refresh" 告警作为「发过请求」的观测信号。 |
| 264 | + const port = await closedPort(); |
| 265 | + const env = makeConfigEnv({ |
| 266 | + api_key: "sk-e2e-dry-run", |
| 267 | + base_url: `http://127.0.0.1:${port}`, |
| 268 | + }); |
| 269 | + const stateBefore = readFileSync(statePath, "utf8"); |
| 270 | + |
| 271 | + // 对照组:不加 --dry-run,默认 refresh 路径真实访问远端 → 出现 refresh 失败告警。 |
| 272 | + const withoutDryRun = await runCommandE2e(ROUTES, planArgs(configPath), env); |
| 273 | + expect(withoutDryRun.stderr).toMatch(/Failed to refresh/i); |
| 274 | + |
| 275 | + // --dry-run:同环境必须完全离线成功,无任何 refresh 痕迹,且不回写 state 文件。 |
| 276 | + const withDryRun = await runCommandE2e(ROUTES, [...planArgs(configPath), "--dry-run"], env); |
| 277 | + expect(withDryRun.exitCode, withDryRun.stderr).toBe(0); |
| 278 | + expect(withDryRun.stderr).not.toMatch(/Failed to refresh/i); |
| 279 | + expect(readFileSync(statePath, "utf8")).toBe(stateBefore); |
| 280 | + }); |
| 281 | +}); |
0 commit comments