niceeval 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. package/dist/agents/types.d.ts +72 -6
  2. package/dist/context/types.d.ts +32 -12
  3. package/dist/i18n/en.d.ts +54 -0
  4. package/dist/i18n/zh-CN.d.ts +55 -1
  5. package/dist/o11y/types.d.ts +16 -2
  6. package/dist/report/aggregate.d.ts +5 -3
  7. package/dist/report/aggregate.js +32 -5
  8. package/dist/report/built-ins/experiment-comparison.d.ts +39 -0
  9. package/dist/report/built-ins/experiment-comparison.js +119 -0
  10. package/dist/report/built-ins/index.d.ts +2 -1
  11. package/dist/report/built-ins/index.js +2 -2
  12. package/dist/report/components.d.ts +10 -2
  13. package/dist/report/components.js +3 -3
  14. package/dist/report/compute.d.ts +11 -18
  15. package/dist/report/compute.js +68 -66
  16. package/dist/report/flag.d.ts +16 -1
  17. package/dist/report/flag.js +19 -1
  18. package/dist/report/format.d.ts +16 -14
  19. package/dist/report/format.js +28 -30
  20. package/dist/report/index.d.ts +5 -4
  21. package/dist/report/index.js +6 -5
  22. package/dist/report/locale.d.ts +23 -3
  23. package/dist/report/locale.js +47 -6
  24. package/dist/report/metrics.d.ts +13 -1
  25. package/dist/report/metrics.js +66 -15
  26. package/dist/report/primitives.d.ts +6 -0
  27. package/dist/report/react/AttemptList.d.ts +4 -4
  28. package/dist/report/react/AttemptList.js +8 -10
  29. package/dist/report/react/EvalList.d.ts +1 -1
  30. package/dist/report/react/EvalList.js +0 -0
  31. package/dist/report/react/ExperimentComparison.d.ts +8 -0
  32. package/dist/report/react/ExperimentComparison.js +11 -0
  33. package/dist/report/react/ExperimentList.d.ts +4 -2
  34. package/dist/report/react/ExperimentList.js +57 -7
  35. package/dist/report/react/MetricScatter.js +6 -16
  36. package/dist/report/react/chart-math.d.ts +23 -6
  37. package/dist/report/react/chart-math.js +71 -19
  38. package/dist/report/react/fixtures.d.ts +3 -3
  39. package/dist/report/react/fixtures.js +30 -18
  40. package/dist/report/react/format.d.ts +1 -1
  41. package/dist/report/react/format.js +1 -1
  42. package/dist/report/react/index.d.ts +1 -1
  43. package/dist/report/report.d.ts +5 -1
  44. package/dist/report/report.js +6 -2
  45. package/dist/report/text/faces.d.ts +1 -1
  46. package/dist/report/text/faces.js +100 -61
  47. package/dist/report/text/table.js +36 -5
  48. package/dist/report/types.d.ts +40 -34
  49. package/dist/results/types.d.ts +11 -0
  50. package/dist/runner/feedback/sink.d.ts +110 -0
  51. package/dist/runner/types.d.ts +513 -22
  52. package/dist/sandbox/docker.d.ts +23 -2
  53. package/dist/sandbox/e2b.d.ts +15 -1
  54. package/dist/sandbox/errors.d.ts +30 -3
  55. package/dist/sandbox/io-retry.d.ts +17 -0
  56. package/dist/sandbox/registry.d.ts +2 -0
  57. package/dist/sandbox/resolve.d.ts +18 -5
  58. package/dist/sandbox/retry.d.ts +11 -1
  59. package/dist/sandbox/types.d.ts +39 -5
  60. package/dist/sandbox/vercel.d.ts +7 -1
  61. package/dist/scoring/coverage.d.ts +30 -0
  62. package/dist/scoring/display.d.ts +21 -0
  63. package/dist/scoring/display.js +120 -0
  64. package/dist/scoring/types.d.ts +103 -20
  65. package/dist/shared/aggregate.d.ts +1 -0
  66. package/dist/shared/aggregate.js +3 -3
  67. package/dist/shared/types.d.ts +28 -0
  68. package/dist/tty-line.d.ts +0 -4
  69. package/dist/util.d.ts +23 -0
  70. package/docs-site/zh/concepts/adapter.mdx +24 -6
  71. package/docs-site/zh/concepts/assert.mdx +11 -10
  72. package/docs-site/zh/concepts/evals.mdx +7 -6
  73. package/docs-site/zh/concepts/experiment.mdx +1 -1
  74. package/docs-site/zh/concepts/overview.mdx +7 -7
  75. package/docs-site/zh/guides/agent-feedback-loop.mdx +35 -31
  76. package/docs-site/zh/guides/authoring.mdx +33 -0
  77. package/docs-site/zh/guides/ci-integration.mdx +23 -12
  78. package/docs-site/zh/guides/connect-your-agent.mdx +29 -3
  79. package/docs-site/zh/guides/custom-reports.mdx +29 -34
  80. package/docs-site/zh/guides/dataset-fanout.mdx +25 -3
  81. package/docs-site/zh/guides/debug-sandbox.mdx +57 -0
  82. package/docs-site/zh/guides/debugging.mdx +210 -0
  83. package/docs-site/zh/guides/experiments.mdx +10 -3
  84. package/docs-site/zh/guides/fixtures.mdx +3 -1
  85. package/docs-site/zh/guides/official-adapters.mdx +27 -3
  86. package/docs-site/zh/guides/publish-report.mdx +30 -16
  87. package/docs-site/zh/guides/report-components.mdx +49 -37
  88. package/docs-site/zh/guides/reporters.mdx +2 -2
  89. package/docs-site/zh/guides/results-data.mdx +42 -8
  90. package/docs-site/zh/guides/runner.mdx +17 -7
  91. package/docs-site/zh/guides/sandbox-agent.mdx +57 -7
  92. package/docs-site/zh/guides/sandbox-providers.mdx +258 -10
  93. package/docs-site/zh/guides/scoring-guide.mdx +4 -4
  94. package/docs-site/zh/guides/viewing-results.mdx +85 -41
  95. package/docs-site/zh/guides/write-experiment.mdx +5 -3
  96. package/docs-site/zh/guides/write-send.mdx +19 -2
  97. package/docs-site/zh/index.mdx +1 -1
  98. package/docs-site/zh/reference/builtin-agents.mdx +27 -0
  99. package/docs-site/zh/reference/capabilities.mdx +2 -2
  100. package/docs-site/zh/reference/cli.mdx +35 -9
  101. package/docs-site/zh/reference/define-agent.mdx +60 -5
  102. package/docs-site/zh/reference/define-config.mdx +1 -1
  103. package/docs-site/zh/reference/define-eval.mdx +42 -9
  104. package/docs-site/zh/reference/events.mdx +2 -2
  105. package/docs-site/zh/reference/expect.mdx +36 -6
  106. package/package.json +5 -1
  107. package/src/agents/ai-sdk-otel.test.ts +1 -0
  108. package/src/agents/ai-sdk.test.ts +3 -0
  109. package/src/agents/ai-sdk.ts +3 -0
  110. package/src/agents/bub-install-spec.test.ts +34 -0
  111. package/src/agents/bub-install-spec.ts +32 -0
  112. package/src/agents/bub.ts +31 -32
  113. package/src/agents/claude-code.test.ts +130 -9
  114. package/src/agents/claude-code.ts +76 -4
  115. package/src/agents/codex.test.ts +189 -40
  116. package/src/agents/codex.ts +155 -14
  117. package/src/agents/coding-cli-versions.test.ts +15 -0
  118. package/src/agents/coding-cli-versions.ts +3 -0
  119. package/src/agents/index.ts +11 -0
  120. package/src/agents/langgraph.test.ts +204 -0
  121. package/src/agents/langgraph.ts +495 -0
  122. package/src/agents/marketplace.ts +85 -0
  123. package/src/agents/native-config.test.ts +179 -0
  124. package/src/agents/native-config.ts +267 -0
  125. package/src/agents/openai-compat.test.ts +1 -0
  126. package/src/agents/openclaw.test.ts +31 -0
  127. package/src/agents/openclaw.ts +171 -0
  128. package/src/agents/plugin-config.test.ts +1 -0
  129. package/src/agents/sdk-streams.test.ts +79 -0
  130. package/src/agents/sdk-streams.ts +55 -10
  131. package/src/agents/skills.test.ts +1 -0
  132. package/src/agents/streaming.test.ts +3 -9
  133. package/src/agents/types.ts +73 -6
  134. package/src/agents/ui-message-stream.test.ts +3 -0
  135. package/src/cli.ts +411 -108
  136. package/src/context/context.test.ts +51 -12
  137. package/src/context/context.ts +161 -29
  138. package/src/context/session.test.ts +1 -0
  139. package/src/context/session.ts +114 -6
  140. package/src/context/types.ts +30 -12
  141. package/src/define.test.ts +13 -8
  142. package/src/define.ts +25 -4
  143. package/src/expect/index.ts +53 -23
  144. package/src/i18n/en.ts +65 -4
  145. package/src/i18n/zh-CN.ts +66 -4
  146. package/src/o11y/cost.test.ts +1 -0
  147. package/src/o11y/execution-tree.test.ts +1 -20
  148. package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
  149. package/src/o11y/otlp/parse.test.ts +1 -0
  150. package/src/o11y/otlp/turn-otel.test.ts +1 -0
  151. package/src/o11y/parsers/bub.test.ts +1 -0
  152. package/src/o11y/parsers/claude-code.test.ts +1 -34
  153. package/src/o11y/parsers/openclaw.test.ts +154 -0
  154. package/src/o11y/parsers/openclaw.ts +310 -0
  155. package/src/o11y/prices.json +746 -311
  156. package/src/o11y/tool-names.test.ts +1 -0
  157. package/src/o11y/types.ts +16 -2
  158. package/src/report/aggregate.ts +34 -5
  159. package/src/report/built-in-user-parity.test.tsx +127 -173
  160. package/src/report/built-ins/experiment-comparison.tsx +179 -0
  161. package/src/report/built-ins/index.ts +7 -2
  162. package/src/report/components.tsx +11 -3
  163. package/src/report/compute.ts +80 -74
  164. package/src/report/dual-render.test.tsx +222 -91
  165. package/src/report/flag.ts +30 -2
  166. package/src/report/format.ts +36 -27
  167. package/src/report/index.ts +23 -6
  168. package/src/report/locale.ts +49 -6
  169. package/src/report/metrics.ts +68 -15
  170. package/src/report/primitives.tsx +6 -0
  171. package/src/report/react/AttemptList.tsx +9 -36
  172. package/src/report/react/EvalList.tsx +0 -0
  173. package/src/report/react/ExperimentComparison.tsx +68 -0
  174. package/src/report/react/ExperimentList.tsx +173 -55
  175. package/src/report/react/MetricScatter.tsx +13 -25
  176. package/src/report/react/chart-math.test.ts +85 -0
  177. package/src/report/react/chart-math.ts +101 -22
  178. package/src/report/react/enhance.js +72 -1
  179. package/src/report/react/fixtures.ts +34 -21
  180. package/src/report/react/format.ts +1 -1
  181. package/src/report/react/index.tsx +0 -1
  182. package/src/report/react/render.test.tsx +30 -69
  183. package/src/report/react/styles.css +112 -14
  184. package/src/report/report.test.ts +308 -105
  185. package/src/report/report.ts +6 -2
  186. package/src/report/text/faces.ts +111 -67
  187. package/src/report/text/table.ts +42 -5
  188. package/src/report/types.ts +42 -34
  189. package/src/results/annotated-source.test.ts +62 -9
  190. package/src/results/annotated-source.ts +64 -6
  191. package/src/results/attempt-evidence.test.ts +9 -7
  192. package/src/results/attempt-evidence.ts +15 -8
  193. package/src/results/attempt-source.ts +6 -3
  194. package/src/results/copy.ts +145 -55
  195. package/src/results/host-equivalence.test.ts +11 -9
  196. package/src/results/index.ts +2 -0
  197. package/src/results/locator.test.ts +1 -22
  198. package/src/results/open.ts +7 -1
  199. package/src/results/publish.ts +149 -0
  200. package/src/results/results.test.ts +85 -51
  201. package/src/results/truncate.ts +90 -0
  202. package/src/results/types.ts +7 -0
  203. package/src/results/writer.ts +31 -13
  204. package/src/runner/attempt.test.ts +138 -7
  205. package/src/runner/attempt.ts +603 -104
  206. package/src/runner/discover.test.ts +47 -0
  207. package/src/runner/discover.ts +36 -2
  208. package/src/runner/eval-source.test.ts +1 -27
  209. package/src/runner/feedback/agent.test.ts +504 -0
  210. package/src/runner/feedback/agent.ts +409 -0
  211. package/src/runner/feedback/ci.test.ts +562 -0
  212. package/src/runner/feedback/ci.ts +401 -0
  213. package/src/runner/feedback/coordinator.test.ts +317 -0
  214. package/src/runner/feedback/coordinator.ts +397 -0
  215. package/src/runner/feedback/failure.ts +40 -0
  216. package/src/runner/feedback/human.test.ts +616 -0
  217. package/src/runner/feedback/human.ts +535 -0
  218. package/src/runner/feedback/index.ts +66 -0
  219. package/src/runner/feedback/io.ts +78 -0
  220. package/src/runner/feedback/profile.test.ts +50 -0
  221. package/src/runner/feedback/profile.ts +58 -0
  222. package/src/runner/feedback/reducer.test.ts +395 -0
  223. package/src/runner/feedback/reducer.ts +260 -0
  224. package/src/runner/feedback/renderer.ts +82 -0
  225. package/src/runner/feedback/sink.ts +203 -0
  226. package/src/runner/feedback/testing.ts +106 -0
  227. package/src/runner/ledger.test.ts +230 -0
  228. package/src/runner/ledger.ts +329 -0
  229. package/src/runner/report.test.ts +128 -3
  230. package/src/runner/report.ts +33 -9
  231. package/src/runner/reporters/artifacts.ts +8 -2
  232. package/src/runner/reporters/braintrust.test.ts +8 -7
  233. package/src/runner/reporters/braintrust.ts +9 -2
  234. package/src/runner/reporters/index.ts +2 -2
  235. package/src/runner/reporters/json.test.ts +162 -0
  236. package/src/runner/reporters/json.ts +35 -8
  237. package/src/runner/reporters/shared.ts +1 -5
  238. package/src/runner/run.test.ts +760 -3
  239. package/src/runner/run.ts +242 -36
  240. package/src/runner/sandbox-prep.ts +3 -42
  241. package/src/runner/timing.ts +158 -0
  242. package/src/runner/types.ts +518 -22
  243. package/src/sandbox/checkpoint.test.ts +55 -0
  244. package/src/sandbox/checkpoint.ts +29 -8
  245. package/src/sandbox/cli-commands.ts +407 -0
  246. package/src/sandbox/docker.ts +115 -16
  247. package/src/sandbox/e2b-agent-template.test.ts +56 -0
  248. package/src/sandbox/e2b-agent-template.ts +94 -0
  249. package/src/sandbox/e2b.ts +74 -9
  250. package/src/sandbox/errors.ts +111 -4
  251. package/src/sandbox/index.ts +2 -0
  252. package/src/sandbox/io-retry.test.ts +58 -0
  253. package/src/sandbox/io-retry.ts +45 -0
  254. package/src/sandbox/keep-registry.test.ts +86 -0
  255. package/src/sandbox/keep-registry.ts +142 -0
  256. package/src/sandbox/keep.ts +178 -0
  257. package/src/sandbox/paths.test.ts +1 -0
  258. package/src/sandbox/paths.ts +19 -8
  259. package/src/sandbox/registry.ts +20 -3
  260. package/src/sandbox/resolve.ts +76 -11
  261. package/src/sandbox/retry.test.ts +70 -0
  262. package/src/sandbox/retry.ts +46 -4
  263. package/src/sandbox/types.ts +44 -6
  264. package/src/sandbox/vercel.ts +43 -20
  265. package/src/scoring/collector.ts +60 -17
  266. package/src/scoring/coverage.ts +95 -0
  267. package/src/scoring/diff.ts +81 -0
  268. package/src/scoring/display.test.ts +121 -0
  269. package/src/scoring/display.ts +133 -0
  270. package/src/scoring/evidence.test.ts +189 -0
  271. package/src/scoring/judge.test.ts +142 -0
  272. package/src/scoring/judge.ts +15 -18
  273. package/src/scoring/scoped.ts +217 -50
  274. package/src/scoring/types.ts +117 -20
  275. package/src/scoring/verdict.ts +16 -4
  276. package/src/shared/aggregate.ts +3 -2
  277. package/src/shared/types.ts +31 -0
  278. package/src/show/compose.ts +2 -2
  279. package/src/show/index.ts +29 -16
  280. package/src/show/render.ts +626 -308
  281. package/src/show/show.test.ts +251 -36
  282. package/src/tty-line.ts +8 -26
  283. package/src/util.test.ts +1 -0
  284. package/src/util.ts +41 -0
  285. package/src/view/app/components/AttemptModal.tsx +153 -2
  286. package/src/view/app/components/CodeView.tsx +32 -11
  287. package/src/view/app/components/CopyControls.tsx +2 -2
  288. package/src/view/app/i18n.ts +6 -0
  289. package/src/view/app/lib/attempt-route.test.ts +1 -0
  290. package/src/view/app/lib/verdict.ts +7 -9
  291. package/src/view/artifact-serving.test.ts +2 -1
  292. package/src/view/client-dist/app.css +1 -1
  293. package/src/view/client-dist/app.js +17 -17
  294. package/src/view/data.test.ts +2 -1
  295. package/src/view/data.ts +17 -7
  296. package/src/view/index.ts +12 -1
  297. package/src/view/server.ts +2 -0
  298. package/src/view/shared/types.ts +1 -1
  299. package/src/view/styles.css +3 -0
  300. package/src/view/view-report.test.ts +11 -10
  301. package/dist/o11y/execution-tree.d.ts +0 -103
  302. package/dist/o11y/otlp/select.d.ts +0 -22
  303. package/dist/report/built-ins/cost-pass-rate-comparison.d.ts +0 -1
  304. package/dist/report/built-ins/cost-pass-rate-comparison.js +0 -17
  305. package/dist/results/annotated-source.d.ts +0 -61
  306. package/dist/results/attempt-evidence.d.ts +0 -69
  307. package/dist/results/attempt-source.d.ts +0 -15
  308. package/src/report/built-ins/cost-pass-rate-comparison.tsx +0 -23
  309. package/src/runner/reporters/console.ts +0 -70
  310. package/src/runner/reporters/live.test.ts +0 -56
  311. package/src/runner/reporters/live.ts +0 -247
  312. package/src/runner/reporters/quiet.test.ts +0 -66
  313. package/src/runner/reporters/quiet.ts +0 -49
  314. package/src/runner/reporters/table.ts +0 -277
@@ -0,0 +1,562 @@
1
+ // cases: docs/engineering/unit-tests/experiments-runner/cases.md
2
+ // CI renderer 测试:全部经真实 FeedbackCoordinator + createFakeFeedbackIO 驱动(不
3
+ // monkey-patch process/Date/setInterval),断言具体文本而不是整段 snapshot diff——逐条对应
4
+ // plan section F 的 checklist 与 docs/feature/experiments/cli.md「CI 怎么用」的例子。
5
+ //
6
+ // 「PR / nightly --no-early-exit / budget incomplete 三类集成测试覆盖文档示例」(checklist
7
+ // 第七条)在文件末尾单独成一个 describe 块,分别对应 cli.md「CI 常见 case」给出的三条命令。
8
+
9
+ import { afterEach, describe, expect, it } from "vitest";
10
+ import { createFeedbackCoordinator, type FeedbackCoordinator } from "./coordinator.ts";
11
+ import { createFakeFeedbackIO, type FakeFeedbackIO } from "./testing.ts";
12
+ import { activeFeedbackSinkCount } from "./sink.ts";
13
+ import { createCiRenderer, computeCiExitCode } from "./ci.ts";
14
+ import type { AttemptLocator } from "../../results/locator.ts";
15
+ import type { AttemptRef, RunCompletion, RunFeedbackPlan, RunSummary } from "../types.ts";
16
+
17
+ function loc(id: string): AttemptLocator {
18
+ return id as AttemptLocator;
19
+ }
20
+
21
+ function ref(evalId: string, attempt = 0, experimentId = "ci/bub"): AttemptRef {
22
+ return { experimentId, evalId, attempt };
23
+ }
24
+
25
+ function plan(overrides: Partial<RunFeedbackPlan["shape"]> = {}, reused = 0): RunFeedbackPlan {
26
+ return {
27
+ shape: { evals: 1, configs: 1, totalRuns: 1, maxConcurrency: 1, ...overrides },
28
+ reused,
29
+ reusedFailures: [],
30
+ };
31
+ }
32
+
33
+ function summary(overrides: Partial<RunSummary> = {}): RunSummary {
34
+ return {
35
+ agent: "codex",
36
+ startedAt: "2026-07-13T00:00:00.000Z",
37
+ completedAt: "2026-07-13T00:03:21.000Z",
38
+ passed: 1,
39
+ failed: 0,
40
+ skipped: 0,
41
+ errored: 0,
42
+ durationMs: 60_000,
43
+ results: [],
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ function completion(overrides: Partial<RunCompletion> = {}): RunCompletion {
49
+ return { status: "complete", unstarted: 0, earlyExitUnstarted: 0, reporterErrors: [], ...overrides };
50
+ }
51
+
52
+ function flush(): Promise<void> {
53
+ return new Promise((resolve) => setTimeout(resolve, 0));
54
+ }
55
+
56
+ /** ANSI escape 起始字节;CI 输出必须永远不出现。 */
57
+ const ESC = "\x1B[";
58
+
59
+ function setup(): { fake: FakeFeedbackIO; coordinator: FeedbackCoordinator } {
60
+ const fake = createFakeFeedbackIO({ stderr: { isTTY: false }, stdout: { isTTY: false } });
61
+ const renderer = createCiRenderer({ io: fake.io });
62
+ const coordinator = createFeedbackCoordinator({ profile: "ci", renderer, io: fake.io, tickIntervalMs: 250 });
63
+ return { fake, coordinator };
64
+ }
65
+
66
+ afterEach(() => {
67
+ expect(activeFeedbackSinkCount()).toBe(0);
68
+ });
69
+
70
+ // ───────────────────────── 单一 stdout sink,不与 stderr 混流 ─────────────────────────
71
+
72
+ describe("CI 正常事件全部走一个 stdout sink", () => {
73
+ it("start/progress/failed/result/json/junit/snapshots 全部在 stdout,stderr 全程为空", async () => {
74
+ const { fake, coordinator } = setup();
75
+ coordinator.start(plan({ totalRuns: 2 }));
76
+ coordinator.emit({
77
+ type: "failure",
78
+ at: 0,
79
+ locator: loc("@x"),
80
+ identity: ref("memory/a"),
81
+ who: "ci/bub",
82
+ verdict: "failed",
83
+ reason: "gate failed",
84
+ assertion: {
85
+ severity: "gate",
86
+ assertion: "Issue 15193: selected proposal matches the accepted proposal",
87
+ matcher: "equals(4)",
88
+ expected: "4",
89
+ received: "3",
90
+ additionalFailures: 0,
91
+ },
92
+ });
93
+ coordinator.diagnostic({ key: "warn-1", severity: "warning", message: "cold cache" });
94
+ await coordinator.finish({
95
+ summary: summary({ passed: 1, failed: 1 }),
96
+ completion: completion(),
97
+ paths: [".niceeval/ci/a/2026"],
98
+ json: ".niceeval/ci-summary.json",
99
+ junit: ".niceeval/junit.xml",
100
+ });
101
+ expect(fake.stderr.writes.join("")).toBe("");
102
+ const text = fake.stdout.writes.join("");
103
+ expect(text).toContain("niceeval: start");
104
+ expect(text).toContain("niceeval: failed");
105
+ expect(text).toContain('severity=gate assertion="Issue 15193: selected proposal matches the accepted proposal" matcher=equals(4) expected=4 received=3');
106
+ expect(text).toContain("niceeval: warning");
107
+ expect(text).toContain("niceeval: result=");
108
+ expect(text).toContain("niceeval: json=");
109
+ expect(text).toContain("niceeval: junit=");
110
+ expect(text).toContain("niceeval: snapshots=");
111
+ });
112
+
113
+ it("不出现任何 ANSI 控制字符", async () => {
114
+ const { fake, coordinator } = setup();
115
+ coordinator.start(plan());
116
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
117
+ expect(fake.stdout.writes.join("") + fake.stderr.writes.join("")).not.toContain(ESC);
118
+ });
119
+ });
120
+
121
+ // ───────────────────────── 固定 ASCII key=value,不随 NICEEVAL_LANG 变化 ─────────────────────────
122
+
123
+ describe("固定 English/ASCII key=value 行", () => {
124
+ it("字段值含空格时用 JSON string 转义,不含空格时保持裸 token", async () => {
125
+ const { fake, coordinator } = setup();
126
+ coordinator.start(plan());
127
+ coordinator.diagnostic({ key: "no-spaces", severity: "warning", message: "cold index warmup" });
128
+ await flush();
129
+ const text = fake.stdout.writes.join("");
130
+ expect(text).toContain('message="cold index warmup"');
131
+ expect(text).toContain("key=no-spaces");
132
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
133
+ });
134
+
135
+ it("字段名与格式不读 io.env——renderer 不接触 NICEEVAL_LANG 之类的环境变量", async () => {
136
+ const fake = createFakeFeedbackIO({
137
+ stderr: { isTTY: false },
138
+ stdout: { isTTY: false },
139
+ env: { NICEEVAL_LANG: "zh-CN" },
140
+ });
141
+ const renderer = createCiRenderer({ io: fake.io });
142
+ const coordinator = createFeedbackCoordinator({ profile: "ci", renderer, io: fake.io, tickIntervalMs: 250 });
143
+ coordinator.start(plan({ totalRuns: 24, configs: 3, maxConcurrency: 10 }, 18));
144
+ await coordinator.finish({ summary: summary({ passed: 23, failed: 1 }), completion: completion(), paths: [] });
145
+ const text = fake.stdout.writes.join("");
146
+ expect(text).toContain("niceeval: start total=24 configs=3 concurrency=10 reused=18");
147
+ expect(text).toContain("niceeval: result=failed passed=23 failed=1 errored=0");
148
+ });
149
+ });
150
+
151
+ // ───────────────────────── start 立即追加 / 60 秒空闲 heartbeat / 永久事件重置时钟 ─────────────────────────
152
+
153
+ describe("start 立即追加;仅连续 60 秒无永久事件才 heartbeat;failure 后重置时钟", () => {
154
+ it("cli.md「CI 怎么用」给出的字面例子:逐行复现", async () => {
155
+ const { fake, coordinator } = setup();
156
+ coordinator.start(plan({ evals: 8, configs: 3, totalRuns: 24, maxConcurrency: 10 }, 18));
157
+
158
+ // 调度把 6 个非携入 attempt(24 total - 18 reused)派发出去。
159
+ for (let i = 0; i < 6; i++) {
160
+ coordinator.emit({ type: "attempt:start", at: 100, identity: ref(`memory/eval-${i}`), who: "ci/bub", phase: "eval.run" });
161
+ }
162
+ fake.advance(60_000); // t=60000:距 "plan"(t=0)满 60s → 第一条 progress heartbeat
163
+ await flush();
164
+
165
+ coordinator.emit({
166
+ type: "failure",
167
+ at: 60_000,
168
+ locator: loc("@7m2k9p"),
169
+ identity: ref("memory/commit0-cachetool"),
170
+ who: "ci/bub",
171
+ verdict: "failed",
172
+ reason: "gate: cache tool not used",
173
+ });
174
+ await flush();
175
+
176
+ // 4 个 attempt 完成(running 6→2,completed 0→4),都在下一次 heartbeat 之前。
177
+ for (let i = 0; i < 4; i++) {
178
+ coordinator.emit({ type: "attempt:complete", at: 60_500 + i, identity: ref(`memory/eval-${i}`), who: "ci/bub", verdict: "passed" });
179
+ }
180
+ fake.advance(60_000); // t=120000:距 failure(t=60000)满 60s → 第二条 heartbeat
181
+ await flush();
182
+
183
+ await coordinator.finish({
184
+ summary: summary({ passed: 23, failed: 1, errored: 0, durationMs: 128_000 }),
185
+ completion: completion(),
186
+ paths: [".niceeval/ci/bub/<snapshot>", ".niceeval/ci/codex/<snapshot>", ".niceeval/ci/claude/<snapshot>"],
187
+ json: ".niceeval/ci-summary.json",
188
+ junit: ".niceeval/junit.xml",
189
+ });
190
+
191
+ const lines = fake.stdout.writes.join("").split("\n").filter(Boolean);
192
+ expect(lines).toEqual([
193
+ "niceeval: start total=24 configs=3 concurrency=10 reused=18",
194
+ "niceeval: progress elapsed=60s reused=18 running=6 queued=0 completed=0",
195
+ 'niceeval: failed locator=@7m2k9p eval=memory/commit0-cachetool experiment=ci/bub reason="gate: cache tool not used"',
196
+ "niceeval: progress elapsed=120s reused=18 running=2 queued=0 completed=4",
197
+ "niceeval: result=failed passed=23 failed=1 errored=0 reused=18 duration=128s",
198
+ "niceeval: json=.niceeval/ci-summary.json",
199
+ "niceeval: junit=.niceeval/junit.xml",
200
+ "niceeval: snapshots=.niceeval/ci/<3 snapshots>",
201
+ ]);
202
+ expect(fake.stderr.writes.join("")).toBe("");
203
+ });
204
+
205
+ it("59.9 秒无永久事件不 heartbeat,满 60 秒才追加一条", async () => {
206
+ const { fake, coordinator } = setup();
207
+ coordinator.start(plan({ totalRuns: 1 }));
208
+ await flush(); // "plan" 的 "start" 行是异步队列任务,先让它落地再取基线计数
209
+ const afterStart = fake.stdout.writes.length;
210
+ expect(afterStart).toBe(1);
211
+
212
+ fake.advance(59_750); // 不到 60s(网格对齐到 250 的倍数)
213
+ await flush();
214
+ expect(fake.stdout.writes.length).toBe(afterStart);
215
+
216
+ fake.advance(250); // 累计满 60000
217
+ await flush();
218
+ expect(fake.stdout.writes.length).toBe(afterStart + 1);
219
+ expect(fake.stdout.writes.at(-1)).toContain("niceeval: progress elapsed=60s");
220
+
221
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
222
+ });
223
+
224
+ it("errored 的 checkpoint 用 'errored' 命令词,携带 phase 字段", async () => {
225
+ const { fake, coordinator } = setup();
226
+ coordinator.start(plan({ totalRuns: 1 }));
227
+ coordinator.emit({
228
+ type: "failure",
229
+ at: 0,
230
+ locator: loc("@2h8m4k1"),
231
+ identity: ref("memory/agent-029-use-cache", 0, "ci/claude-e2b"),
232
+ who: "ci/claude-e2b",
233
+ verdict: "errored",
234
+ reason: "E2B sandbox allocation failed after 5 attempts",
235
+ phase: "sandbox.create",
236
+ });
237
+ await flush();
238
+ const text = fake.stdout.writes.join("");
239
+ expect(text).toContain(
240
+ 'niceeval: errored locator=@2h8m4k1 eval=memory/agent-029-use-cache experiment=ci/claude-e2b phase=sandbox.create reason="E2B sandbox allocation failed after 5 attempts"',
241
+ );
242
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
243
+ });
244
+
245
+ it("diagnostic 出现后重置时钟:紧接着的 heartbeat 相对 diagnostic 的时间戳,不是相对 start", async () => {
246
+ const { fake, coordinator } = setup();
247
+ coordinator.start(plan({ totalRuns: 1 }));
248
+ fake.advance(40_000);
249
+ coordinator.diagnostic({ key: "memory-warmup-degraded", severity: "warning", message: "continuing with a cold index" });
250
+ await flush();
251
+ fake.advance(59_750); // 距 diagnostic(t=40000)不到 60s
252
+ await flush();
253
+ const beforeHeartbeat = fake.stdout.writes.filter((w) => w.includes("progress")).length;
254
+ expect(beforeHeartbeat).toBe(0);
255
+ fake.advance(250); // 距 diagnostic 满 60s(t=100000)
256
+ await flush();
257
+ expect(fake.stdout.writes.filter((w) => w.includes("progress"))).toHaveLength(1);
258
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
259
+ });
260
+ });
261
+
262
+ // ───────────────────────── passed 不逐条打印;failed/errored 立即打印;展开上限 50 ─────────────────────────
263
+
264
+ describe("passed 不逐条打印;failed/errored 立即打印 locator;默认展开上限 50", () => {
265
+ it("passed attempt 的 lifecycle 事件不产生任何额外 stdout 输出", async () => {
266
+ const { fake, coordinator } = setup();
267
+ coordinator.start(plan({ totalRuns: 3 }));
268
+ await flush(); // "plan" 的 "start" 行是异步队列任务,先让它落地再取基线计数
269
+ const afterStart = fake.stdout.writes.length;
270
+ coordinator.emit({ type: "attempt:start", at: 0, identity: ref("memory/a"), who: "ci/bub", phase: "eval.run" });
271
+ coordinator.emit({ type: "attempt:phase", at: 1, identity: ref("memory/a"), phase: "scoring.evaluate" });
272
+ coordinator.emit({ type: "attempt:progress", at: 2, identity: ref("memory/a"), detail: "tool: shell" });
273
+ coordinator.emit({ type: "attempt:complete", at: 3, identity: ref("memory/a"), who: "ci/bub", verdict: "passed" });
274
+ await flush();
275
+ expect(fake.stdout.writes.length).toBe(afterStart);
276
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
277
+ });
278
+
279
+ it("51 条 failure 只展开 50 条,第 51 条给一次 suppressed 提示,完整结果不丢", async () => {
280
+ const { fake, coordinator } = setup();
281
+ coordinator.start(plan({ totalRuns: 51 }));
282
+ for (let i = 0; i < 51; i++) {
283
+ coordinator.emit({
284
+ type: "failure",
285
+ at: i,
286
+ locator: loc(`@f${i}`),
287
+ identity: ref(`memory/eval-${i}`),
288
+ who: `who-${i}`,
289
+ verdict: "failed",
290
+ reason: "gate failed",
291
+ });
292
+ }
293
+ await flush();
294
+ expect(coordinator.state.failures).toHaveLength(51); // reducer 完整保留,不因展开上限丢数据
295
+
296
+ const text = fake.stdout.writes.join("");
297
+ for (let i = 0; i < 50; i++) expect(text).toContain(`memory/eval-${i}`);
298
+ expect(text).not.toContain("memory/eval-50");
299
+ expect(text).toContain("failures-suppressed");
300
+ expect(text.split("failures-suppressed").length - 1).toBe(1);
301
+
302
+ await coordinator.finish({ summary: summary({ passed: 0, failed: 51 }), completion: completion(), paths: [] });
303
+ });
304
+ });
305
+
306
+ // ───────────────────────── result 收尾:status/counts/reused/unstarted/duration + 产物路径 ─────────────────────────
307
+
308
+ describe("最后一条 result 行独立说明 status/counts/reused/unstarted/duration;随后打印实际生成的产物路径", () => {
309
+ it("全部通过:result=passed,不带 unstarted 字段", async () => {
310
+ const { fake, coordinator } = setup();
311
+ coordinator.start(plan({ totalRuns: 5 }, 2));
312
+ await coordinator.finish({
313
+ summary: summary({ passed: 5, failed: 0, errored: 0, durationMs: 12_000 }),
314
+ completion: completion(),
315
+ paths: [],
316
+ });
317
+ const text = fake.stdout.writes.join("");
318
+ expect(text).toContain("niceeval: result=passed passed=5 failed=0 errored=0 reused=2 duration=12s");
319
+ expect(text).not.toContain("unstarted=");
320
+ });
321
+
322
+ it("只传 --junit 不传 --json 时,只打印 junit= 行", async () => {
323
+ const { fake, coordinator } = setup();
324
+ coordinator.start(plan({ totalRuns: 1 }));
325
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [], junit: ".niceeval/junit.xml" });
326
+ const text = fake.stdout.writes.join("");
327
+ expect(text).toContain("niceeval: junit=.niceeval/junit.xml");
328
+ expect(text).not.toContain("niceeval: json=");
329
+ });
330
+
331
+ it("单条快照路径直接打印,不折叠成 <1 snapshots>", async () => {
332
+ const { fake, coordinator } = setup();
333
+ coordinator.start(plan({ totalRuns: 1 }));
334
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [".niceeval/ci/only/2026"] });
335
+ const text = fake.stdout.writes.join("");
336
+ expect(text).toContain("niceeval: snapshots=.niceeval/ci/only/2026");
337
+ });
338
+
339
+ it("多组不同 experiment 前缀的快照路径:折叠成不带前缀的 <N snapshots>", async () => {
340
+ const { fake, coordinator } = setup();
341
+ coordinator.start(plan({ totalRuns: 2 }));
342
+ await coordinator.finish({
343
+ summary: summary(),
344
+ completion: completion(),
345
+ paths: [".niceeval/pr/a/2026", ".niceeval/nightly/b/2026"],
346
+ });
347
+ const text = fake.stdout.writes.join("");
348
+ expect(text).toContain("niceeval: snapshots=<2 snapshots>");
349
+ });
350
+
351
+ it("incomplete 时状态词优先于 verdict 计数,result 行带 unstarted 不带 reused", async () => {
352
+ const { fake, coordinator } = setup();
353
+ coordinator.start(plan({ totalRuns: 40 }));
354
+ await coordinator.finish({
355
+ summary: summary({ passed: 36, failed: 0, errored: 0, durationMs: 5_000 }),
356
+ completion: completion({ status: "incomplete", unstarted: 4 }),
357
+ paths: [],
358
+ });
359
+ const text = fake.stdout.writes.join("");
360
+ const resultLine = text.split("\n").find((l) => l.startsWith("niceeval: result="));
361
+ expect(resultLine).toBe("niceeval: result=incomplete passed=36 failed=0 errored=0 unstarted=4 duration=5s");
362
+ expect(resultLine).not.toContain("reused="); // reused=0(未携入)不出现在 result 行——与 start 行的
363
+ // 字面 "reused=0" 无关,那一行本来就无条件带这个字段(见 "niceeval: start" 的实现)。
364
+ });
365
+ });
366
+
367
+ // ───────────────────────── budget-exhausted / interrupted / reporter-error:去重后追加一次 ─────────────────────────
368
+
369
+ describe("diagnostic 类永久事件去重后只追加一次", () => {
370
+ it("同一 budget-exhausted experimentId 触发 3 次只打印第一次", async () => {
371
+ const { fake, coordinator } = setup();
372
+ coordinator.start(plan({ totalRuns: 3 }));
373
+ for (let i = 0; i < 3; i++) {
374
+ coordinator.emit({ type: "budget-exhausted", at: i, experimentId: "regression/codex", spent: 25.31, unstarted: i + 1 });
375
+ }
376
+ await flush();
377
+ const text = fake.stdout.writes.join("");
378
+ expect(text.split("budget_exhausted").length - 1).toBe(1);
379
+ expect(text).toContain("niceeval: budget_exhausted experiment=regression/codex spent=25.31 unstarted=1");
380
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
381
+ });
382
+
383
+ it("interrupted 只追加一次,退出码由 CompletionStatus 决定为 130", async () => {
384
+ const { fake, coordinator } = setup();
385
+ coordinator.start(plan({ totalRuns: 1 }));
386
+ coordinator.interrupted();
387
+ coordinator.interrupted();
388
+ await flush();
389
+ const text = fake.stdout.writes.join("");
390
+ expect(text.split("niceeval: interrupted").length - 1).toBe(1);
391
+ expect(text).toContain("niceeval: interrupted elapsed=0s");
392
+ const runCompletion = completion({ status: "interrupted" });
393
+ await coordinator.finish({ summary: summary({ passed: 0, failed: 0 }), completion: runCompletion, paths: [] });
394
+ expect(computeCiExitCode(summary({ passed: 0, failed: 0 }), runCompletion)).toBe(130);
395
+ });
396
+
397
+ it("reporter-error 去重后追加一次,携带 required 字段", async () => {
398
+ const { fake, coordinator } = setup();
399
+ coordinator.start(plan({ totalRuns: 1 }));
400
+ coordinator.reporterError({ reporter: "artifacts", required: true, message: "EACCES: permission denied" });
401
+ coordinator.reporterError({ reporter: "artifacts", required: true, message: "EACCES: permission denied" });
402
+ await flush();
403
+ const text = fake.stdout.writes.join("");
404
+ expect(text.split("reporter_error").length - 1).toBe(1);
405
+ expect(text).toContain('niceeval: reporter_error reporter=artifacts required=true message="EACCES: permission denied"');
406
+ await coordinator.finish({ summary: summary(), completion: completion(), paths: [] });
407
+ });
408
+ });
409
+
410
+ // ───────────────────────── 退出码:budget → incomplete 非零;required reporter → 非零;中断 → 130 ─────────────────────────
411
+
412
+ describe("computeCiExitCode:CompletionStatus 驱动退出码,不只看 failed/errored", () => {
413
+ it("全部通过、complete → 0", () => {
414
+ expect(computeCiExitCode(summary({ passed: 5, failed: 0, errored: 0 }), completion())).toBe(0);
415
+ });
416
+
417
+ it("有 failed → 1", () => {
418
+ expect(computeCiExitCode(summary({ passed: 4, failed: 1 }), completion())).toBe(1);
419
+ });
420
+
421
+ it("有 errored → 1", () => {
422
+ expect(computeCiExitCode(summary({ passed: 4, errored: 1 }), completion())).toBe(1);
423
+ });
424
+
425
+ it("budget 耗尽导致 unstarted、completion.status=incomplete → 1,即便全部已跑的都通过", () => {
426
+ expect(
427
+ computeCiExitCode(summary({ passed: 36, failed: 0, errored: 0 }), completion({ status: "incomplete", unstarted: 4 })),
428
+ ).toBe(1);
429
+ });
430
+
431
+ it("用户/平台中断、completion.status=interrupted → 130", () => {
432
+ expect(computeCiExitCode(summary({ passed: 3, failed: 0, errored: 0 }), completion({ status: "interrupted" }))).toBe(130);
433
+ });
434
+
435
+ it("required reporter 失败 → 1,即便全部 attempt 都通过", () => {
436
+ expect(
437
+ computeCiExitCode(
438
+ summary({ passed: 10, failed: 0, errored: 0 }),
439
+ completion({ reporterErrors: [{ reporter: "artifacts", required: true, message: "EACCES" }] }),
440
+ ),
441
+ ).toBe(1);
442
+ });
443
+
444
+ it("best-effort(非 required)reporter 失败不强制非零", () => {
445
+ expect(
446
+ computeCiExitCode(
447
+ summary({ passed: 10, failed: 0, errored: 0 }),
448
+ completion({ reporterErrors: [{ reporter: "custom", required: false, message: "network blip" }] }),
449
+ ),
450
+ ).toBe(0);
451
+ });
452
+
453
+ it("首过即停省略的 earlyExitUnstarted 不影响退出码(不是 budget 的 unstarted)", () => {
454
+ expect(
455
+ computeCiExitCode(summary({ passed: 10, failed: 0, errored: 0 }), completion({ earlyExitUnstarted: 6, unstarted: 0 })),
456
+ ).toBe(0);
457
+ });
458
+ });
459
+
460
+ // ───────────────────────── 三类集成测试覆盖文档示例:PR / nightly --no-early-exit / budget incomplete ─────────────────────────
461
+
462
+ describe("集成测试覆盖 cli.md「CI 常见 case」的三条命令", () => {
463
+ it("PR 快速门禁(--runs 1 --junit ...):只出 junit,首过即停省略数不计入 unstarted/退出码", async () => {
464
+ const { fake, coordinator } = setup();
465
+ coordinator.start(plan({ evals: 6, configs: 1, totalRuns: 6 }));
466
+ for (let i = 0; i < 6; i++) {
467
+ coordinator.emit({ type: "attempt:start", at: 0, identity: ref(`memory/eval-${i}`, 0, "pr/bub"), who: "pr/bub", phase: "eval.run" });
468
+ }
469
+ for (let i = 0; i < 5; i++) {
470
+ coordinator.emit({ type: "attempt:complete", at: i + 1, identity: ref(`memory/eval-${i}`, 0, "pr/bub"), who: "pr/bub", verdict: "passed" });
471
+ }
472
+ coordinator.emit({
473
+ type: "failure",
474
+ at: 6,
475
+ locator: loc("@pr1"),
476
+ identity: ref("memory/eval-5", 0, "pr/bub"),
477
+ who: "pr/bub",
478
+ verdict: "failed",
479
+ reason: "gate: cache tool not used",
480
+ });
481
+ // --runs 1 结构上不可能有首过即停跳过,但即便上游意外传了非零 earlyExitUnstarted,
482
+ // 这条防线也不能让它泄漏进 unstarted= 字段或退出码(见 plan 第 5 节的显式区分)。
483
+ const runCompletion = completion({ earlyExitUnstarted: 0, unstarted: 0 });
484
+ await coordinator.finish({
485
+ summary: summary({ passed: 5, failed: 1, errored: 0, durationMs: 30_000 }),
486
+ completion: runCompletion,
487
+ paths: [".niceeval/pr/bub/2026"],
488
+ junit: ".niceeval/junit.xml",
489
+ });
490
+ const text = fake.stdout.writes.join("");
491
+ expect(text).toContain("niceeval: junit=.niceeval/junit.xml");
492
+ expect(text).not.toContain("niceeval: json=");
493
+ expect(text).not.toContain("unstarted=");
494
+ expect(computeCiExitCode(summary({ passed: 5, failed: 1, errored: 0 }), runCompletion)).toBe(1);
495
+ });
496
+
497
+ it("nightly --no-early-exit --runs 5:真实分母,零 early-exit 事件,json+junit 都出", async () => {
498
+ const { fake, coordinator } = setup();
499
+ coordinator.start(plan({ evals: 1, configs: 1, totalRuns: 5 }));
500
+ // --no-early-exit:整段模拟里从不 emit "attempt:early-exit"——真实分母来自 5 次都跑完,
501
+ // 不是靠首过即停省略掉后面几次(见 cli.md「runs 与首过即停怎样展示」)。
502
+ for (let i = 0; i < 5; i++) {
503
+ coordinator.emit({
504
+ type: "attempt:start",
505
+ at: i,
506
+ identity: ref("memory/retention", i, "nightly/bub"),
507
+ who: "nightly/bub",
508
+ phase: "eval.run",
509
+ });
510
+ }
511
+ for (let i = 0; i < 5; i++) {
512
+ const verdict = i < 2 ? "failed" : "passed"; // 2/5 failed,--no-early-exit 后仍跑满 5 次
513
+ coordinator.emit({
514
+ type: "attempt:complete",
515
+ at: 10 + i,
516
+ identity: ref("memory/retention", i, "nightly/bub"),
517
+ who: "nightly/bub",
518
+ verdict,
519
+ });
520
+ if (verdict === "failed") {
521
+ coordinator.emit({
522
+ type: "failure",
523
+ at: 10 + i,
524
+ locator: loc(`@night${i}`),
525
+ identity: ref("memory/retention", i, "nightly/bub"),
526
+ who: "nightly/bub",
527
+ verdict: "failed",
528
+ reason: "flaked",
529
+ });
530
+ }
531
+ }
532
+ await coordinator.finish({
533
+ summary: summary({ passed: 3, failed: 2, errored: 0, durationMs: 300_000 }),
534
+ completion: completion({ earlyExitUnstarted: 0, unstarted: 0 }),
535
+ paths: [".niceeval/nightly/bub/2026"],
536
+ json: ".niceeval/nightly.json",
537
+ junit: ".niceeval/nightly.xml",
538
+ });
539
+ const text = fake.stdout.writes.join("");
540
+ expect(text).toContain("niceeval: json=.niceeval/nightly.json");
541
+ expect(text).toContain("niceeval: junit=.niceeval/nightly.xml");
542
+ expect(text).toContain("niceeval: result=failed passed=3 failed=2 errored=0 duration=300s");
543
+ expect(computeCiExitCode(summary({ passed: 3, failed: 2 }), completion())).toBe(1);
544
+ });
545
+
546
+ it("budget incomplete(cli.md 字面例子的语义):budget_exhausted → result=incomplete,退出码非零", async () => {
547
+ const { fake, coordinator } = setup();
548
+ coordinator.start(plan({ evals: 40, configs: 1, totalRuns: 40 }));
549
+ coordinator.emit({ type: "budget-exhausted", at: 1_000, experimentId: "regression/codex", spent: 25.31, unstarted: 4 });
550
+ await flush();
551
+ const runCompletion = completion({ status: "incomplete", unstarted: 4 });
552
+ await coordinator.finish({
553
+ summary: summary({ passed: 36, failed: 0, errored: 0, durationMs: 1_082_000 }),
554
+ completion: runCompletion,
555
+ paths: [],
556
+ });
557
+ const text = fake.stdout.writes.join("");
558
+ expect(text).toContain("niceeval: budget_exhausted experiment=regression/codex spent=25.31 unstarted=4");
559
+ expect(text).toContain("niceeval: result=incomplete passed=36 failed=0 errored=0 unstarted=4 duration=1082s");
560
+ expect(computeCiExitCode(summary({ passed: 36, failed: 0, errored: 0 }), runCompletion)).toBe(1);
561
+ });
562
+ });