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.
- package/dist/agents/types.d.ts +72 -6
- package/dist/context/types.d.ts +32 -12
- package/dist/i18n/en.d.ts +54 -0
- package/dist/i18n/zh-CN.d.ts +55 -1
- package/dist/o11y/types.d.ts +16 -2
- package/dist/report/aggregate.d.ts +5 -3
- package/dist/report/aggregate.js +32 -5
- package/dist/report/built-ins/experiment-comparison.d.ts +39 -0
- package/dist/report/built-ins/experiment-comparison.js +119 -0
- package/dist/report/built-ins/index.d.ts +2 -1
- package/dist/report/built-ins/index.js +2 -2
- package/dist/report/components.d.ts +10 -2
- package/dist/report/components.js +3 -3
- package/dist/report/compute.d.ts +11 -18
- package/dist/report/compute.js +68 -66
- package/dist/report/flag.d.ts +16 -1
- package/dist/report/flag.js +19 -1
- package/dist/report/format.d.ts +16 -14
- package/dist/report/format.js +28 -30
- package/dist/report/index.d.ts +5 -4
- package/dist/report/index.js +6 -5
- package/dist/report/locale.d.ts +23 -3
- package/dist/report/locale.js +47 -6
- package/dist/report/metrics.d.ts +13 -1
- package/dist/report/metrics.js +66 -15
- package/dist/report/primitives.d.ts +6 -0
- package/dist/report/react/AttemptList.d.ts +4 -4
- package/dist/report/react/AttemptList.js +8 -10
- package/dist/report/react/EvalList.d.ts +1 -1
- package/dist/report/react/EvalList.js +0 -0
- package/dist/report/react/ExperimentComparison.d.ts +8 -0
- package/dist/report/react/ExperimentComparison.js +11 -0
- package/dist/report/react/ExperimentList.d.ts +4 -2
- package/dist/report/react/ExperimentList.js +57 -7
- package/dist/report/react/MetricScatter.js +6 -16
- package/dist/report/react/chart-math.d.ts +23 -6
- package/dist/report/react/chart-math.js +71 -19
- package/dist/report/react/fixtures.d.ts +3 -3
- package/dist/report/react/fixtures.js +30 -18
- package/dist/report/react/format.d.ts +1 -1
- package/dist/report/react/format.js +1 -1
- package/dist/report/react/index.d.ts +1 -1
- package/dist/report/report.d.ts +5 -1
- package/dist/report/report.js +6 -2
- package/dist/report/text/faces.d.ts +1 -1
- package/dist/report/text/faces.js +100 -61
- package/dist/report/text/table.js +36 -5
- package/dist/report/types.d.ts +40 -34
- package/dist/results/types.d.ts +11 -0
- package/dist/runner/feedback/sink.d.ts +110 -0
- package/dist/runner/types.d.ts +513 -22
- package/dist/sandbox/docker.d.ts +23 -2
- package/dist/sandbox/e2b.d.ts +15 -1
- package/dist/sandbox/errors.d.ts +30 -3
- package/dist/sandbox/io-retry.d.ts +17 -0
- package/dist/sandbox/registry.d.ts +2 -0
- package/dist/sandbox/resolve.d.ts +18 -5
- package/dist/sandbox/retry.d.ts +11 -1
- package/dist/sandbox/types.d.ts +39 -5
- package/dist/sandbox/vercel.d.ts +7 -1
- package/dist/scoring/coverage.d.ts +30 -0
- package/dist/scoring/display.d.ts +21 -0
- package/dist/scoring/display.js +120 -0
- package/dist/scoring/types.d.ts +103 -20
- package/dist/shared/aggregate.d.ts +1 -0
- package/dist/shared/aggregate.js +3 -3
- package/dist/shared/types.d.ts +28 -0
- package/dist/tty-line.d.ts +0 -4
- package/dist/util.d.ts +23 -0
- package/docs-site/zh/concepts/adapter.mdx +24 -6
- package/docs-site/zh/concepts/assert.mdx +11 -10
- package/docs-site/zh/concepts/evals.mdx +7 -6
- package/docs-site/zh/concepts/experiment.mdx +1 -1
- package/docs-site/zh/concepts/overview.mdx +7 -7
- package/docs-site/zh/guides/agent-feedback-loop.mdx +35 -31
- package/docs-site/zh/guides/authoring.mdx +33 -0
- package/docs-site/zh/guides/ci-integration.mdx +23 -12
- package/docs-site/zh/guides/connect-your-agent.mdx +29 -3
- package/docs-site/zh/guides/custom-reports.mdx +29 -34
- package/docs-site/zh/guides/dataset-fanout.mdx +25 -3
- package/docs-site/zh/guides/debug-sandbox.mdx +57 -0
- package/docs-site/zh/guides/debugging.mdx +210 -0
- package/docs-site/zh/guides/experiments.mdx +10 -3
- package/docs-site/zh/guides/fixtures.mdx +3 -1
- package/docs-site/zh/guides/official-adapters.mdx +27 -3
- package/docs-site/zh/guides/publish-report.mdx +30 -16
- package/docs-site/zh/guides/report-components.mdx +49 -37
- package/docs-site/zh/guides/reporters.mdx +2 -2
- package/docs-site/zh/guides/results-data.mdx +42 -8
- package/docs-site/zh/guides/runner.mdx +17 -7
- package/docs-site/zh/guides/sandbox-agent.mdx +57 -7
- package/docs-site/zh/guides/sandbox-providers.mdx +258 -10
- package/docs-site/zh/guides/scoring-guide.mdx +4 -4
- package/docs-site/zh/guides/viewing-results.mdx +85 -41
- package/docs-site/zh/guides/write-experiment.mdx +5 -3
- package/docs-site/zh/guides/write-send.mdx +19 -2
- package/docs-site/zh/index.mdx +1 -1
- package/docs-site/zh/reference/builtin-agents.mdx +27 -0
- package/docs-site/zh/reference/capabilities.mdx +2 -2
- package/docs-site/zh/reference/cli.mdx +35 -9
- package/docs-site/zh/reference/define-agent.mdx +60 -5
- package/docs-site/zh/reference/define-config.mdx +1 -1
- package/docs-site/zh/reference/define-eval.mdx +42 -9
- package/docs-site/zh/reference/events.mdx +2 -2
- package/docs-site/zh/reference/expect.mdx +36 -6
- package/package.json +5 -1
- package/src/agents/ai-sdk-otel.test.ts +1 -0
- package/src/agents/ai-sdk.test.ts +3 -0
- package/src/agents/ai-sdk.ts +3 -0
- package/src/agents/bub-install-spec.test.ts +34 -0
- package/src/agents/bub-install-spec.ts +32 -0
- package/src/agents/bub.ts +31 -32
- package/src/agents/claude-code.test.ts +130 -9
- package/src/agents/claude-code.ts +76 -4
- package/src/agents/codex.test.ts +189 -40
- package/src/agents/codex.ts +155 -14
- package/src/agents/coding-cli-versions.test.ts +15 -0
- package/src/agents/coding-cli-versions.ts +3 -0
- package/src/agents/index.ts +11 -0
- package/src/agents/langgraph.test.ts +204 -0
- package/src/agents/langgraph.ts +495 -0
- package/src/agents/marketplace.ts +85 -0
- package/src/agents/native-config.test.ts +179 -0
- package/src/agents/native-config.ts +267 -0
- package/src/agents/openai-compat.test.ts +1 -0
- package/src/agents/openclaw.test.ts +31 -0
- package/src/agents/openclaw.ts +171 -0
- package/src/agents/plugin-config.test.ts +1 -0
- package/src/agents/sdk-streams.test.ts +79 -0
- package/src/agents/sdk-streams.ts +55 -10
- package/src/agents/skills.test.ts +1 -0
- package/src/agents/streaming.test.ts +3 -9
- package/src/agents/types.ts +73 -6
- package/src/agents/ui-message-stream.test.ts +3 -0
- package/src/cli.ts +411 -108
- package/src/context/context.test.ts +51 -12
- package/src/context/context.ts +161 -29
- package/src/context/session.test.ts +1 -0
- package/src/context/session.ts +114 -6
- package/src/context/types.ts +30 -12
- package/src/define.test.ts +13 -8
- package/src/define.ts +25 -4
- package/src/expect/index.ts +53 -23
- package/src/i18n/en.ts +65 -4
- package/src/i18n/zh-CN.ts +66 -4
- package/src/o11y/cost.test.ts +1 -0
- package/src/o11y/execution-tree.test.ts +1 -20
- package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
- package/src/o11y/otlp/parse.test.ts +1 -0
- package/src/o11y/otlp/turn-otel.test.ts +1 -0
- package/src/o11y/parsers/bub.test.ts +1 -0
- package/src/o11y/parsers/claude-code.test.ts +1 -34
- package/src/o11y/parsers/openclaw.test.ts +154 -0
- package/src/o11y/parsers/openclaw.ts +310 -0
- package/src/o11y/prices.json +746 -311
- package/src/o11y/tool-names.test.ts +1 -0
- package/src/o11y/types.ts +16 -2
- package/src/report/aggregate.ts +34 -5
- package/src/report/built-in-user-parity.test.tsx +127 -173
- package/src/report/built-ins/experiment-comparison.tsx +179 -0
- package/src/report/built-ins/index.ts +7 -2
- package/src/report/components.tsx +11 -3
- package/src/report/compute.ts +80 -74
- package/src/report/dual-render.test.tsx +222 -91
- package/src/report/flag.ts +30 -2
- package/src/report/format.ts +36 -27
- package/src/report/index.ts +23 -6
- package/src/report/locale.ts +49 -6
- package/src/report/metrics.ts +68 -15
- package/src/report/primitives.tsx +6 -0
- package/src/report/react/AttemptList.tsx +9 -36
- package/src/report/react/EvalList.tsx +0 -0
- package/src/report/react/ExperimentComparison.tsx +68 -0
- package/src/report/react/ExperimentList.tsx +173 -55
- package/src/report/react/MetricScatter.tsx +13 -25
- package/src/report/react/chart-math.test.ts +85 -0
- package/src/report/react/chart-math.ts +101 -22
- package/src/report/react/enhance.js +72 -1
- package/src/report/react/fixtures.ts +34 -21
- package/src/report/react/format.ts +1 -1
- package/src/report/react/index.tsx +0 -1
- package/src/report/react/render.test.tsx +30 -69
- package/src/report/react/styles.css +112 -14
- package/src/report/report.test.ts +308 -105
- package/src/report/report.ts +6 -2
- package/src/report/text/faces.ts +111 -67
- package/src/report/text/table.ts +42 -5
- package/src/report/types.ts +42 -34
- package/src/results/annotated-source.test.ts +62 -9
- package/src/results/annotated-source.ts +64 -6
- package/src/results/attempt-evidence.test.ts +9 -7
- package/src/results/attempt-evidence.ts +15 -8
- package/src/results/attempt-source.ts +6 -3
- package/src/results/copy.ts +145 -55
- package/src/results/host-equivalence.test.ts +11 -9
- package/src/results/index.ts +2 -0
- package/src/results/locator.test.ts +1 -22
- package/src/results/open.ts +7 -1
- package/src/results/publish.ts +149 -0
- package/src/results/results.test.ts +85 -51
- package/src/results/truncate.ts +90 -0
- package/src/results/types.ts +7 -0
- package/src/results/writer.ts +31 -13
- package/src/runner/attempt.test.ts +138 -7
- package/src/runner/attempt.ts +603 -104
- package/src/runner/discover.test.ts +47 -0
- package/src/runner/discover.ts +36 -2
- package/src/runner/eval-source.test.ts +1 -27
- package/src/runner/feedback/agent.test.ts +504 -0
- package/src/runner/feedback/agent.ts +409 -0
- package/src/runner/feedback/ci.test.ts +562 -0
- package/src/runner/feedback/ci.ts +401 -0
- package/src/runner/feedback/coordinator.test.ts +317 -0
- package/src/runner/feedback/coordinator.ts +397 -0
- package/src/runner/feedback/failure.ts +40 -0
- package/src/runner/feedback/human.test.ts +616 -0
- package/src/runner/feedback/human.ts +535 -0
- package/src/runner/feedback/index.ts +66 -0
- package/src/runner/feedback/io.ts +78 -0
- package/src/runner/feedback/profile.test.ts +50 -0
- package/src/runner/feedback/profile.ts +58 -0
- package/src/runner/feedback/reducer.test.ts +395 -0
- package/src/runner/feedback/reducer.ts +260 -0
- package/src/runner/feedback/renderer.ts +82 -0
- package/src/runner/feedback/sink.ts +203 -0
- package/src/runner/feedback/testing.ts +106 -0
- package/src/runner/ledger.test.ts +230 -0
- package/src/runner/ledger.ts +329 -0
- package/src/runner/report.test.ts +128 -3
- package/src/runner/report.ts +33 -9
- package/src/runner/reporters/artifacts.ts +8 -2
- package/src/runner/reporters/braintrust.test.ts +8 -7
- package/src/runner/reporters/braintrust.ts +9 -2
- package/src/runner/reporters/index.ts +2 -2
- package/src/runner/reporters/json.test.ts +162 -0
- package/src/runner/reporters/json.ts +35 -8
- package/src/runner/reporters/shared.ts +1 -5
- package/src/runner/run.test.ts +760 -3
- package/src/runner/run.ts +242 -36
- package/src/runner/sandbox-prep.ts +3 -42
- package/src/runner/timing.ts +158 -0
- package/src/runner/types.ts +518 -22
- package/src/sandbox/checkpoint.test.ts +55 -0
- package/src/sandbox/checkpoint.ts +29 -8
- package/src/sandbox/cli-commands.ts +407 -0
- package/src/sandbox/docker.ts +115 -16
- package/src/sandbox/e2b-agent-template.test.ts +56 -0
- package/src/sandbox/e2b-agent-template.ts +94 -0
- package/src/sandbox/e2b.ts +74 -9
- package/src/sandbox/errors.ts +111 -4
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/io-retry.test.ts +58 -0
- package/src/sandbox/io-retry.ts +45 -0
- package/src/sandbox/keep-registry.test.ts +86 -0
- package/src/sandbox/keep-registry.ts +142 -0
- package/src/sandbox/keep.ts +178 -0
- package/src/sandbox/paths.test.ts +1 -0
- package/src/sandbox/paths.ts +19 -8
- package/src/sandbox/registry.ts +20 -3
- package/src/sandbox/resolve.ts +76 -11
- package/src/sandbox/retry.test.ts +70 -0
- package/src/sandbox/retry.ts +46 -4
- package/src/sandbox/types.ts +44 -6
- package/src/sandbox/vercel.ts +43 -20
- package/src/scoring/collector.ts +60 -17
- package/src/scoring/coverage.ts +95 -0
- package/src/scoring/diff.ts +81 -0
- package/src/scoring/display.test.ts +121 -0
- package/src/scoring/display.ts +133 -0
- package/src/scoring/evidence.test.ts +189 -0
- package/src/scoring/judge.test.ts +142 -0
- package/src/scoring/judge.ts +15 -18
- package/src/scoring/scoped.ts +217 -50
- package/src/scoring/types.ts +117 -20
- package/src/scoring/verdict.ts +16 -4
- package/src/shared/aggregate.ts +3 -2
- package/src/shared/types.ts +31 -0
- package/src/show/compose.ts +2 -2
- package/src/show/index.ts +29 -16
- package/src/show/render.ts +626 -308
- package/src/show/show.test.ts +251 -36
- package/src/tty-line.ts +8 -26
- package/src/util.test.ts +1 -0
- package/src/util.ts +41 -0
- package/src/view/app/components/AttemptModal.tsx +153 -2
- package/src/view/app/components/CodeView.tsx +32 -11
- package/src/view/app/components/CopyControls.tsx +2 -2
- package/src/view/app/i18n.ts +6 -0
- package/src/view/app/lib/attempt-route.test.ts +1 -0
- package/src/view/app/lib/verdict.ts +7 -9
- package/src/view/artifact-serving.test.ts +2 -1
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +17 -17
- package/src/view/data.test.ts +2 -1
- package/src/view/data.ts +17 -7
- package/src/view/index.ts +12 -1
- package/src/view/server.ts +2 -0
- package/src/view/shared/types.ts +1 -1
- package/src/view/styles.css +3 -0
- package/src/view/view-report.test.ts +11 -10
- package/dist/o11y/execution-tree.d.ts +0 -103
- package/dist/o11y/otlp/select.d.ts +0 -22
- package/dist/report/built-ins/cost-pass-rate-comparison.d.ts +0 -1
- package/dist/report/built-ins/cost-pass-rate-comparison.js +0 -17
- package/dist/results/annotated-source.d.ts +0 -61
- package/dist/results/attempt-evidence.d.ts +0 -69
- package/dist/results/attempt-source.d.ts +0 -15
- package/src/report/built-ins/cost-pass-rate-comparison.tsx +0 -23
- package/src/runner/reporters/console.ts +0 -70
- package/src/runner/reporters/live.test.ts +0 -56
- package/src/runner/reporters/live.ts +0 -247
- package/src/runner/reporters/quiet.test.ts +0 -66
- package/src/runner/reporters/quiet.ts +0 -49
- package/src/runner/reporters/table.ts +0 -277
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
1
|
+
// cases: docs/engineering/unit-tests/experiments-runner/cases.md
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { emitReporterEvent, filterSummary, runReporter, scopeReporter } from "./report.ts";
|
|
4
|
+
import { activateFeedbackSink, activeFeedbackSinkCount } from "./feedback/sink.ts";
|
|
5
|
+
import type { Agent, EvalResult, Reporter, ReporterRegistration, RunShape, RunSummary } from "../types.ts";
|
|
4
6
|
|
|
5
7
|
function result(id: string, overrides: Partial<EvalResult> = {}): EvalResult {
|
|
6
8
|
return {
|
|
@@ -107,3 +109,126 @@ describe("scopeReporter", () => {
|
|
|
107
109
|
expect(scoped.onEvent).toBeUndefined();
|
|
108
110
|
});
|
|
109
111
|
});
|
|
112
|
+
|
|
113
|
+
// runReporter()/emitReporterEvent() 是「required/best-effort」判定实际生效的地方(见
|
|
114
|
+
// `ReporterRegistration` 的字段注释):它们只负责把 reg.name/reg.required 原样转发进
|
|
115
|
+
// `reportReporterError()`,不做判定本身——判定(是否让 completion/CI 退出码判红)在下游
|
|
116
|
+
// (coordinator → reducer → cli.ts 的 assembleRunCompletion)。这里用一个假 FeedbackSink
|
|
117
|
+
// 直接断言转发的字段,不需要拉起整个 coordinator。
|
|
118
|
+
describe("runReporter / emitReporterEvent · required/best-effort 原样转发,不吞错也不中断其它 reporter", () => {
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
// 每个 activateFeedbackSink() 都要在测试内退出,避免遗留在 sink.ts 的活跃栈里
|
|
121
|
+
// 污染下一个测试(与 feedback/coordinator.test.ts 同一条兜底校验)。
|
|
122
|
+
expect(activeFeedbackSinkCount()).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
function withFakeSink<T>(
|
|
126
|
+
fn: (calls: { reporter: string; required: boolean; message: string }[]) => Promise<T>,
|
|
127
|
+
): Promise<T> {
|
|
128
|
+
const calls: { reporter: string; required: boolean; message: string }[] = [];
|
|
129
|
+
const deactivate = activateFeedbackSink({
|
|
130
|
+
activity() {},
|
|
131
|
+
diagnostic() {},
|
|
132
|
+
interrupted() {},
|
|
133
|
+
reporterError(input) {
|
|
134
|
+
calls.push(input);
|
|
135
|
+
},
|
|
136
|
+
failure() {},
|
|
137
|
+
budgetExhausted() {},
|
|
138
|
+
kept() {},
|
|
139
|
+
lifecycle() {},
|
|
140
|
+
});
|
|
141
|
+
return fn(calls).finally(deactivate);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
it("required reporter 抛错:reportReporterError 收到注册时的真实 name 与 required=true,而不是 stage 名字", () =>
|
|
145
|
+
withFakeSink(async (calls) => {
|
|
146
|
+
const reg: ReporterRegistration = { reporter: {}, name: "artifacts", required: true };
|
|
147
|
+
await runReporter(reg, "onEvalComplete", () => {
|
|
148
|
+
throw new Error("disk full");
|
|
149
|
+
});
|
|
150
|
+
expect(calls).toHaveLength(1);
|
|
151
|
+
expect(calls[0]).toMatchObject({ reporter: "artifacts", required: true });
|
|
152
|
+
// stage 是失败发生阶段的次要上下文,拼进 message,不覆盖 reporter 字段本身。
|
|
153
|
+
expect(calls[0]!.message).toContain("onEvalComplete");
|
|
154
|
+
expect(calls[0]!.message).toContain("disk full");
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
it("message 只含 formatThrown() 的第一行,不把完整 .stack(本地绝对路径 + 调用帧)灌进机器 envelope", () =>
|
|
158
|
+
withFakeSink(async (calls) => {
|
|
159
|
+
const reg: ReporterRegistration = { reporter: {}, name: "json", required: true };
|
|
160
|
+
await runReporter(reg, "onRunComplete", () => {
|
|
161
|
+
// 真实 Error 的 .stack 恒为多行:第一行 "Error: message",之后每行一个 " at ..." 调用帧
|
|
162
|
+
// (含本地绝对文件路径)。reportReporterError 的 message 是喂给 agent/ci 的单行 key=value
|
|
163
|
+
// envelope 里的一个字段值,不是 EvalResult.error 那种有专门落盘位置的完整记录——必须只取
|
|
164
|
+
// 第一行,不能把调用栈和本地路径原样透传出去。
|
|
165
|
+
throw new Error("EISDIR: illegal operation on a directory, rename");
|
|
166
|
+
});
|
|
167
|
+
expect(calls).toHaveLength(1);
|
|
168
|
+
const { message } = calls[0]!;
|
|
169
|
+
expect(message).toBe("onRunComplete: Error: EISDIR: illegal operation on a directory, rename");
|
|
170
|
+
expect(message).not.toContain("\n");
|
|
171
|
+
expect(message).not.toContain(" at ");
|
|
172
|
+
expect(message).not.toContain(import.meta.url.replace("file://", "")); // 本文件自己的绝对路径不出现在调用帧里
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
it("best-effort reporter(如 config.reporters)抛错同样上报,但 required=false", () =>
|
|
176
|
+
withFakeSink(async (calls) => {
|
|
177
|
+
const reg: ReporterRegistration = { reporter: {}, name: "config-reporter-0", required: false };
|
|
178
|
+
await runReporter(reg, "onRunComplete", () => {
|
|
179
|
+
throw new Error("network blip");
|
|
180
|
+
});
|
|
181
|
+
expect(calls[0]).toMatchObject({ reporter: "config-reporter-0", required: false });
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
it("runReporter 永不 reject——即便 reporter 抛错,调用方(Promise.all 聚合)仍能等到它 resolve", () =>
|
|
185
|
+
withFakeSink(async () => {
|
|
186
|
+
await expect(
|
|
187
|
+
runReporter({ reporter: {}, name: "x", required: true }, "onRunStart", () => {
|
|
188
|
+
throw new Error("boom");
|
|
189
|
+
}),
|
|
190
|
+
).resolves.toBeUndefined();
|
|
191
|
+
}));
|
|
192
|
+
|
|
193
|
+
it("emitReporterEvent 对每个注册项独立兜错:一个 reporter 抛错不阻止其它 reporter 收到同一个事件", () =>
|
|
194
|
+
withFakeSink(async (calls) => {
|
|
195
|
+
const seen: string[] = [];
|
|
196
|
+
const throwing: ReporterRegistration = {
|
|
197
|
+
reporter: {
|
|
198
|
+
onEvent: () => {
|
|
199
|
+
throw new Error("boom");
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
name: "throwing",
|
|
203
|
+
required: false,
|
|
204
|
+
};
|
|
205
|
+
const ok: ReporterRegistration = {
|
|
206
|
+
reporter: { onEvent: (e) => void seen.push(e.type) },
|
|
207
|
+
name: "ok",
|
|
208
|
+
required: false,
|
|
209
|
+
};
|
|
210
|
+
await emitReporterEvent([throwing, ok], { type: "run:earlyExit", evalId: "e/1" });
|
|
211
|
+
expect(seen).toEqual(["run:earlyExit"]); // ok reporter 仍然收到了同一个事件
|
|
212
|
+
expect(calls).toHaveLength(1);
|
|
213
|
+
expect(calls[0]).toMatchObject({ reporter: "throwing", required: false });
|
|
214
|
+
}));
|
|
215
|
+
|
|
216
|
+
it("没有活跃 coordinator 时(run 未激活 / 已 finish)退回 bootstrap stderr,不吞错、仍然 resolve", async () => {
|
|
217
|
+
expect(activeFeedbackSinkCount()).toBe(0); // 确认这条测试真的走的是「没有活跃 sink」分支
|
|
218
|
+
const writes: string[] = [];
|
|
219
|
+
const spy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
|
220
|
+
writes.push(String(chunk));
|
|
221
|
+
return true;
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
await expect(
|
|
225
|
+
runReporter({ reporter: {}, name: "artifacts", required: true }, "onRunStart", () => {
|
|
226
|
+
throw new Error("disk full");
|
|
227
|
+
}),
|
|
228
|
+
).resolves.toBeUndefined();
|
|
229
|
+
expect(writes.join("")).toContain("disk full");
|
|
230
|
+
} finally {
|
|
231
|
+
spy.mockRestore();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
package/src/runner/report.ts
CHANGED
|
@@ -1,22 +1,46 @@
|
|
|
1
1
|
// reporter 编排与运行级汇总。reporter 是「结果消费方」:单个 reporter 抛错只记
|
|
2
2
|
// diagnostic,不能让整次调度崩。
|
|
3
3
|
|
|
4
|
-
import type {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import type {
|
|
5
|
+
EvalResult,
|
|
6
|
+
LocalizedText,
|
|
7
|
+
Reporter,
|
|
8
|
+
ReporterEvent,
|
|
9
|
+
ReporterRegistration,
|
|
10
|
+
RunShape,
|
|
11
|
+
RunSummary,
|
|
12
|
+
} from "../types.ts";
|
|
13
|
+
import { firstLine, formatThrown } from "../util.ts";
|
|
14
|
+
import { reportReporterError } from "./feedback/sink.ts";
|
|
8
15
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
16
|
+
/**
|
|
17
|
+
* reporter 调用的统一兜错。返回 void,永不 reject(供 Promise.all 安全聚合,单个 reporter
|
|
18
|
+
* 抛错不终止其它 reporter 的收尾,也不中断在飞的 attempt)。`reg.name`/`reg.required` 决定
|
|
19
|
+
* `reportReporterError()` 收到的诊断身份与判定权重(见 `ReporterRegistration` 的字段注释:
|
|
20
|
+
* 同一个 reporter 反复失败按 `name` 去重折叠,`required` 决定它是否让 completion/CI 退出码
|
|
21
|
+
* 判红)。`stage` 只是这次失败发生在哪个回调阶段的次要上下文,拼进 message,不参与去重身份——
|
|
22
|
+
* 同一 reporter 在 onEvalComplete 与 onRunComplete 两个阶段各失败一次,仍然折成同一条诊断、
|
|
23
|
+
* count 累加到 2,而不是拆成两条互不相关的诊断。message 只取 `formatThrown()` 的第一行(与
|
|
24
|
+
* run.ts 的 `describeFailureReason` 同一原则)——reporter 抛出的 Error 完整 `.stack` 含本地
|
|
25
|
+
* 绝对文件路径和调用帧,不适合塞进 agent/ci 那种单行、稳定、机器消费的 envelope;这里没有
|
|
26
|
+
* `EvalResult.error` 那样的落盘字段能保留完整栈,所以「一层可行动摘要」就是这条诊断的全部内容,
|
|
27
|
+
* 不是从更详细的记录里摘出来的简化版——排查真正需要栈时,重跑该 reporter 单独复现更可靠。
|
|
28
|
+
*/
|
|
29
|
+
export async function runReporter(reg: ReporterRegistration, stage: string, fn: () => unknown): Promise<void> {
|
|
11
30
|
try {
|
|
12
31
|
await fn();
|
|
13
32
|
} catch (e) {
|
|
14
|
-
|
|
33
|
+
reportReporterError({ reporter: reg.name, required: reg.required, message: `${stage}: ${firstLine(formatThrown(e))}` });
|
|
15
34
|
}
|
|
16
35
|
}
|
|
17
36
|
|
|
18
|
-
export async function emitReporterEvent(
|
|
19
|
-
|
|
37
|
+
export async function emitReporterEvent(
|
|
38
|
+
registrations: readonly ReporterRegistration[],
|
|
39
|
+
event: ReporterEvent,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
await Promise.all(
|
|
42
|
+
registrations.map((reg) => runReporter(reg, `event:${event.type}`, () => reg.reporter.onEvent?.(event))),
|
|
43
|
+
);
|
|
20
44
|
}
|
|
21
45
|
|
|
22
46
|
/** 按 eval id 过滤 RunSummary 并重新计数 —— eval 级 reporter 只看它观测的那部分。 */
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// 重数据分文件,snapshot.json 只留快照元数据,view 展开某条 trace 时再按需 fetch 它的 trace.json。
|
|
8
8
|
|
|
9
9
|
import { readFile } from "node:fs/promises";
|
|
10
|
-
import type { Reporter } from "../../types.ts";
|
|
10
|
+
import type { Reporter, RunShape } from "../../types.ts";
|
|
11
11
|
import { createResultsWriter, type ResultsWriter } from "../../results/writer.ts";
|
|
12
12
|
|
|
13
13
|
/** niceeval 自身的 npm 版本,写进 producer.version;版本不匹配时读取器靠它拼 npx 提示。 */
|
|
@@ -28,11 +28,17 @@ export function Artifacts(root = ".niceeval"): ArtifactsReporter {
|
|
|
28
28
|
return {
|
|
29
29
|
outputDirs: () => writer?.snapshotDirs() ?? [],
|
|
30
30
|
|
|
31
|
-
async onRunStart() {
|
|
31
|
+
async onRunStart(_evals, _agent, shape?: RunShape) {
|
|
32
32
|
// 每次 run 换一个新 writer(同一个 reporter 实例可能被复用):writer 内部按
|
|
33
33
|
// experimentId 懒建各自的快照目录,这里只重置引用。
|
|
34
|
+
// snapshotStartedAt 显式接收自 runner(shape.snapshotStartedAt,见 RunShape 的注释)——
|
|
35
|
+
// 不再各自按「该 experiment 第一条落盘 result 的 attempt startedAt」猜,与 runner
|
|
36
|
+
// 在 result.locator 里编码的身份锚点是同一个值。省略只出现在没有真实 shape 的
|
|
37
|
+
// 直调场景(如测试手写 Reporter 调用),此时退回 createResultsWriter 自己的兜底
|
|
38
|
+
// (result.startedAt,见 writer.ts 的 ResultsWriterOptions.snapshotStartedAt)。
|
|
34
39
|
writer = createResultsWriter(root, {
|
|
35
40
|
producer: { name: "niceeval", version: await producerVersion() },
|
|
41
|
+
snapshotStartedAt: shape?.snapshotStartedAt,
|
|
36
42
|
});
|
|
37
43
|
},
|
|
38
44
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// cases: docs/engineering/unit-tests/experiments-runner/cases.md
|
|
1
2
|
import { describe, expect, it } from "vitest";
|
|
2
3
|
import { toBraintrustEvent } from "./braintrust.ts";
|
|
3
4
|
import type { EvalResult } from "../../types.ts";
|
|
@@ -19,8 +20,8 @@ describe("toBraintrustEvent", () => {
|
|
|
19
20
|
const event = toBraintrustEvent(
|
|
20
21
|
baseResult({
|
|
21
22
|
assertions: [
|
|
22
|
-
{ name: "compiles", severity: "gate", score: 1,
|
|
23
|
-
{ name: "closedQA", severity: "soft", score: 0.7,
|
|
23
|
+
{ name: "compiles", severity: "gate", score: 1, outcome: "passed" as const },
|
|
24
|
+
{ name: "closedQA", severity: "soft", score: 0.7, outcome: "passed" as const },
|
|
24
25
|
],
|
|
25
26
|
}),
|
|
26
27
|
);
|
|
@@ -31,9 +32,9 @@ describe("toBraintrustEvent", () => {
|
|
|
31
32
|
const event = toBraintrustEvent(
|
|
32
33
|
baseResult({
|
|
33
34
|
assertions: [
|
|
34
|
-
{ name: "check", severity: "soft", score: 0.5,
|
|
35
|
-
{ name: "check", severity: "soft", score: 2,
|
|
36
|
-
{ name: "check", severity: "soft", score: -1,
|
|
35
|
+
{ name: "check", severity: "soft", score: 0.5, outcome: "passed" as const },
|
|
36
|
+
{ name: "check", severity: "soft", score: 2, outcome: "passed" as const },
|
|
37
|
+
{ name: "check", severity: "soft", score: -1, outcome: "failed" as const },
|
|
37
38
|
],
|
|
38
39
|
}),
|
|
39
40
|
);
|
|
@@ -85,8 +86,8 @@ describe("toBraintrustEvent", () => {
|
|
|
85
86
|
model: "gpt-5.2",
|
|
86
87
|
attempt: 1,
|
|
87
88
|
verdict: "failed",
|
|
88
|
-
experiment: {
|
|
89
|
-
assertions: [{ name: "compiles", severity: "gate", score: 0,
|
|
89
|
+
experiment: { flags: { tape: true }, runs: 1, earlyExit: true, selectedEvalIds: [] },
|
|
90
|
+
assertions: [{ name: "compiles", severity: "gate", score: 0, outcome: "failed" as const, detail: "tsc failed" }],
|
|
90
91
|
}),
|
|
91
92
|
);
|
|
92
93
|
expect(event.metadata).toEqual({
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// (reporter 错误按框架约定只记 diagnostic,不会让运行崩)。
|
|
5
5
|
|
|
6
6
|
import type { EvalResult, Reporter } from "../../types.ts";
|
|
7
|
+
import { reportActivity } from "../feedback/sink.ts";
|
|
7
8
|
|
|
8
9
|
export interface BraintrustConfig {
|
|
9
10
|
/** Braintrust 项目名;省略时用 "niceeval"。 */
|
|
@@ -98,7 +99,7 @@ export function Braintrust(config: BraintrustConfig = {}): Reporter {
|
|
|
98
99
|
await sdk?.flush();
|
|
99
100
|
const summary = await experiment.summarize();
|
|
100
101
|
if (summary.experimentUrl) {
|
|
101
|
-
|
|
102
|
+
reportActivity(`Braintrust experiment: ${summary.experimentUrl}`);
|
|
102
103
|
}
|
|
103
104
|
} finally {
|
|
104
105
|
experiment = undefined;
|
|
@@ -118,6 +119,8 @@ export function Braintrust(config: BraintrustConfig = {}): Reporter {
|
|
|
118
119
|
export function toBraintrustEvent(result: EvalResult): BraintrustLogEvent {
|
|
119
120
|
const scores: Record<string, number> = {};
|
|
120
121
|
for (const a of result.assertions) {
|
|
122
|
+
// unavailable 是没有分数的独立态:不写 0 分(评不了 ≠ 0 分),缺口进 metadata。
|
|
123
|
+
if (a.outcome === "unavailable") continue;
|
|
121
124
|
const base = a.severity === "gate" ? `gate:${a.name}` : a.name;
|
|
122
125
|
let key = base;
|
|
123
126
|
for (let n = 2; key in scores; n++) key = `${base}#${n}`;
|
|
@@ -155,9 +158,13 @@ export function toBraintrustEvent(result: EvalResult): BraintrustLogEvent {
|
|
|
155
158
|
}
|
|
156
159
|
if (result.skipReason !== undefined) metadata.skipReason = result.skipReason;
|
|
157
160
|
const failed = result.assertions
|
|
158
|
-
.filter((a) =>
|
|
161
|
+
.filter((a) => a.outcome === "failed")
|
|
159
162
|
.map((a) => ({ name: a.name, detail: a.detail }));
|
|
160
163
|
if (failed.length > 0) metadata.failedAssertions = failed;
|
|
164
|
+
const unavailable = result.assertions
|
|
165
|
+
.filter((a) => a.outcome === "unavailable")
|
|
166
|
+
.map((a) => ({ name: a.name, reason: a.outcome === "unavailable" ? a.reason : "" }));
|
|
167
|
+
if (unavailable.length > 0) metadata.unavailableAssertions = unavailable;
|
|
161
168
|
|
|
162
169
|
// 一次运行内 (experiment, eval, agent, model, attempt) 唯一;Braintrust 按 id 合并重复行。
|
|
163
170
|
const id = [result.experimentId ?? "", result.id, result.agent, result.model ?? "", `a${result.attempt}`].join("|");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// 内置报告器:
|
|
1
|
+
// 内置报告器:Artifacts(默认落盘)/ Json / JUnit / Braintrust。
|
|
2
2
|
// 其它第三方实验跟踪平台也走同一条 Reporter 通道。
|
|
3
|
+
// 终端反馈不是 Reporter——三种 --output profile 见 src/runner/feedback/。
|
|
3
4
|
|
|
4
|
-
export { Console } from "./console.ts";
|
|
5
5
|
export { Artifacts } from "./artifacts.ts";
|
|
6
6
|
export { Json, JUnit } from "./json.ts";
|
|
7
7
|
export { Braintrust, type BraintrustConfig } from "./braintrust.ts";
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// cases: docs/engineering/unit-tests/experiments-runner/cases.md
|
|
2
|
+
// Json/JUnit 的同目录 temp → write → rename 原子替换契约(见 docs/feature/experiments/cli.md
|
|
3
|
+
// 「输出流和落盘节奏」):成功后不留 temp;写入失败保留旧目标文件、不留半成品、不留 temp。
|
|
4
|
+
//
|
|
5
|
+
// `node:fs/promises` 的具名导出在这个 Vitest 环境下是不可配置的 ESM 绑定(`vi.spyOn` 直接
|
|
6
|
+
// 报 "Module namespace is not configurable")——只能靠 `vi.mock` 在模块解析层整体替换,
|
|
7
|
+
// 其余全部转发给真实实现(`importOriginal`),只在 `failureState` 显式设置时让 rename/
|
|
8
|
+
// writeFile 失败一次(用后即清,不影响后续调用或其它测试)。
|
|
9
|
+
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { mkdtemp, readdir, readFile, rm, writeFile as realWriteFile } from "node:fs/promises";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { Json, JUnit } from "./json.ts";
|
|
15
|
+
import type { RunSummary } from "../../types.ts";
|
|
16
|
+
|
|
17
|
+
const failureState = vi.hoisted(() => ({
|
|
18
|
+
renameError: undefined as Error | undefined,
|
|
19
|
+
writeFileError: undefined as Error | undefined,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
23
|
+
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
|
24
|
+
return {
|
|
25
|
+
...actual,
|
|
26
|
+
rename: async (...args: Parameters<typeof actual.rename>) => {
|
|
27
|
+
if (failureState.renameError) {
|
|
28
|
+
const e = failureState.renameError;
|
|
29
|
+
failureState.renameError = undefined;
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
32
|
+
return actual.rename(...args);
|
|
33
|
+
},
|
|
34
|
+
writeFile: async (...args: Parameters<typeof actual.writeFile>) => {
|
|
35
|
+
if (failureState.writeFileError) {
|
|
36
|
+
const e = failureState.writeFileError;
|
|
37
|
+
failureState.writeFileError = undefined;
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
return actual.writeFile(...args);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function summary(overrides: Partial<RunSummary> = {}): RunSummary {
|
|
46
|
+
return {
|
|
47
|
+
agent: "codex",
|
|
48
|
+
startedAt: "2026-07-07T00:00:00.000Z",
|
|
49
|
+
completedAt: "2026-07-07T00:01:00.000Z",
|
|
50
|
+
passed: 1,
|
|
51
|
+
failed: 0,
|
|
52
|
+
skipped: 0,
|
|
53
|
+
errored: 0,
|
|
54
|
+
durationMs: 1000,
|
|
55
|
+
results: [],
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const roots: string[] = [];
|
|
61
|
+
async function makeRoot(): Promise<string> {
|
|
62
|
+
const root = await mkdtemp(join(tmpdir(), "niceeval-json-atomic-"));
|
|
63
|
+
roots.push(root);
|
|
64
|
+
return root;
|
|
65
|
+
}
|
|
66
|
+
afterEach(async () => {
|
|
67
|
+
await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })));
|
|
68
|
+
failureState.renameError = undefined;
|
|
69
|
+
failureState.writeFileError = undefined;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** 目录里是否残留 `.tmp` 临时文件(atomicWriteFile 用 `.niceeval-<uuid>.tmp` 命名)。 */
|
|
73
|
+
async function tmpFilesIn(dir: string): Promise<string[]> {
|
|
74
|
+
return (await readdir(dir)).filter((name) => name.endsWith(".tmp"));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("Json · 原子替换", () => {
|
|
78
|
+
it("成功写入合法 JSON,写完不留 temp 文件", async () => {
|
|
79
|
+
const root = await makeRoot();
|
|
80
|
+
const path = join(root, "out.json");
|
|
81
|
+
|
|
82
|
+
await Json(path).onRunComplete!(summary({ passed: 3 }));
|
|
83
|
+
|
|
84
|
+
const parsed = JSON.parse(await readFile(path, "utf-8"));
|
|
85
|
+
expect(parsed.passed).toBe(3);
|
|
86
|
+
expect(await tmpFilesIn(root)).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rename 失败时保留旧目标文件原内容,不留截断 JSON,不留 temp 文件", async () => {
|
|
90
|
+
const root = await makeRoot();
|
|
91
|
+
const path = join(root, "out.json");
|
|
92
|
+
await realWriteFile(path, '{"old":"content"}', "utf-8"); // 上一轮已经成功写过一份
|
|
93
|
+
|
|
94
|
+
failureState.renameError = new Error("simulated rename failure");
|
|
95
|
+
await expect(Json(path).onRunComplete!(summary({ passed: 99 }))).rejects.toThrow("simulated rename failure");
|
|
96
|
+
|
|
97
|
+
// 旧内容原封不动——没有被替换成半成品,也没有被新内容(passed: 99)覆盖。
|
|
98
|
+
expect(await readFile(path, "utf-8")).toBe('{"old":"content"}');
|
|
99
|
+
expect(await tmpFilesIn(root)).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("写临时文件本身失败时不产生新目标文件,也不留 temp 文件", async () => {
|
|
103
|
+
const root = await makeRoot();
|
|
104
|
+
const path = join(root, "out.json"); // 目标此前不存在
|
|
105
|
+
|
|
106
|
+
failureState.writeFileError = new Error("simulated write failure");
|
|
107
|
+
await expect(Json(path).onRunComplete!(summary())).rejects.toThrow("simulated write failure");
|
|
108
|
+
|
|
109
|
+
expect(await readdir(root)).toEqual([]); // 没有凭空出现半成品目标文件
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("rename 目标是一个已存在目录时必然失败(POSIX 语义,不依赖权限/是否 root)——旧目录原样保留,不留 temp 文件", async () => {
|
|
113
|
+
const root = await makeRoot();
|
|
114
|
+
const path = join(root, "out.json");
|
|
115
|
+
const { mkdir } = await import("node:fs/promises");
|
|
116
|
+
await mkdir(path); // 占用目标路径的不是文件而是目录——rename 一个文件覆盖已存在目录恒失败
|
|
117
|
+
|
|
118
|
+
await expect(Json(path).onRunComplete!(summary())).rejects.toThrow();
|
|
119
|
+
|
|
120
|
+
const entries = await readdir(root);
|
|
121
|
+
expect(entries).toEqual(["out.json"]); // 目录还在,没有被替换、也没有多出 temp 文件残留
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("JUnit · 原子替换", () => {
|
|
126
|
+
it("成功写入合法 XML,写完不留 temp 文件", async () => {
|
|
127
|
+
const root = await makeRoot();
|
|
128
|
+
const path = join(root, "out.xml");
|
|
129
|
+
|
|
130
|
+
await JUnit(path).onRunComplete!(
|
|
131
|
+
summary({
|
|
132
|
+
results: [
|
|
133
|
+
{
|
|
134
|
+
id: "a/1",
|
|
135
|
+
agent: "codex",
|
|
136
|
+
verdict: "passed",
|
|
137
|
+
attempt: 0,
|
|
138
|
+
durationMs: 10,
|
|
139
|
+
assertions: [],
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const xml = await readFile(path, "utf-8");
|
|
146
|
+
expect(xml).toContain("<?xml");
|
|
147
|
+
expect(xml).toContain("<testsuite");
|
|
148
|
+
expect(await tmpFilesIn(root)).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("rename 失败时保留旧目标文件原内容,不留截断 XML,不留 temp 文件", async () => {
|
|
152
|
+
const root = await makeRoot();
|
|
153
|
+
const path = join(root, "out.xml");
|
|
154
|
+
await realWriteFile(path, "<old/>", "utf-8");
|
|
155
|
+
|
|
156
|
+
failureState.renameError = new Error("simulated rename failure");
|
|
157
|
+
await expect(JUnit(path).onRunComplete!(summary())).rejects.toThrow("simulated rename failure");
|
|
158
|
+
|
|
159
|
+
expect(await readFile(path, "utf-8")).toBe("<old/>");
|
|
160
|
+
expect(await tmpFilesIn(root)).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -1,14 +1,42 @@
|
|
|
1
1
|
// JSON / JUnit 报告器:把运行结果落成机器可读 artifact,接 CI 或下游 dashboard。
|
|
2
|
+
// 两者都是「整次运行结束时写一份聚合文件」(不像 Artifacts 的逐 attempt 增量落盘),见
|
|
3
|
+
// docs/feature/experiments/cli.md「输出流和落盘节奏」——一旦开始写就必须原子替换目标:
|
|
4
|
+
// 半途失败(磁盘满、权限问题、进程被杀)不能把上一次成功的 JSON/JUnit 覆盖成截断文件,
|
|
5
|
+
// 那会让 CI 读到一份看似存在、实际损坏的报告,比「文件不存在」更危险。
|
|
2
6
|
|
|
3
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
-
import { dirname } from "node:path";
|
|
7
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
5
10
|
import type { Reporter, RunSummary } from "../../types.ts";
|
|
6
11
|
|
|
12
|
+
/**
|
|
13
|
+
* 同目录 temp → write → rename 的原子替换:内容先整体写进同目录下的临时文件,写完才
|
|
14
|
+
* `rename()` 覆盖真正的目标路径——`rename()` 在同一文件系统内是单一原子操作,读者不可能
|
|
15
|
+
* 观察到「文件存在但内容只写了一半」的中间态。同目录是必须的,跨目录/跨文件系统的
|
|
16
|
+
* rename 不保证原子性(可能退化成先复制再删除)。
|
|
17
|
+
*
|
|
18
|
+
* 失败路径:临时文件写入或 rename 本身抛错时,`rename()` 还没被调用或没有成功,旧目标文件
|
|
19
|
+
* (如果之前存在)完全没被触碰;没有旧目标文件时目录里也不会凭空出现一个新的。失败后立即
|
|
20
|
+
* 清理临时文件(`rm force`,即便清理本身失败也吞掉——不能让「清理垃圾」的次要错误掩盖调用方
|
|
21
|
+
* 真正需要看到的写入失败),成功路径下 `rename()` 之后临时文件已经不在原路径,同样不留残留。
|
|
22
|
+
*/
|
|
23
|
+
async function atomicWriteFile(path: string, content: string): Promise<void> {
|
|
24
|
+
const dir = dirname(path);
|
|
25
|
+
await mkdir(dir, { recursive: true });
|
|
26
|
+
const tmpPath = join(dir, `.niceeval-${randomUUID()}.tmp`);
|
|
27
|
+
try {
|
|
28
|
+
await writeFile(tmpPath, content, "utf-8");
|
|
29
|
+
await rename(tmpPath, path);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
await rm(tmpPath, { force: true }).catch(() => {});
|
|
32
|
+
throw e;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
7
36
|
export function Json(path: string): Reporter {
|
|
8
37
|
return {
|
|
9
38
|
async onRunComplete(summary: RunSummary) {
|
|
10
|
-
await
|
|
11
|
-
await writeFile(path, JSON.stringify(summary, null, 2), "utf-8");
|
|
39
|
+
await atomicWriteFile(path, JSON.stringify(summary, null, 2));
|
|
12
40
|
},
|
|
13
41
|
};
|
|
14
42
|
}
|
|
@@ -16,16 +44,15 @@ export function Json(path: string): Reporter {
|
|
|
16
44
|
export function JUnit(path: string): Reporter {
|
|
17
45
|
return {
|
|
18
46
|
async onRunComplete(summary: RunSummary) {
|
|
19
|
-
await mkdir(dirname(path), { recursive: true });
|
|
20
47
|
const cases = summary.results
|
|
21
48
|
.map((r) => {
|
|
22
49
|
const name = xmlAttr(`${r.id} [${r.agent}${r.model ? "/" + r.model : ""}]`);
|
|
23
50
|
const time = (r.durationMs / 1000).toFixed(3);
|
|
24
51
|
if (r.verdict === "errored") {
|
|
25
|
-
return ` <testcase name="${name}" time="${time}"><error message="${xmlAttr(r.error ?? "execution error")}"/></testcase>`;
|
|
52
|
+
return ` <testcase name="${name}" time="${time}"><error message="${xmlAttr(r.error?.message ?? "execution error")}"/></testcase>`;
|
|
26
53
|
}
|
|
27
54
|
if (r.verdict === "failed") {
|
|
28
|
-
const msg = xmlAttr(r.assertions.filter((a) =>
|
|
55
|
+
const msg = xmlAttr(r.assertions.filter((a) => a.outcome === "failed").map((a) => a.name).join("; "));
|
|
29
56
|
return ` <testcase name="${name}" time="${time}"><failure message="${msg}"/></testcase>`;
|
|
30
57
|
}
|
|
31
58
|
if (r.verdict === "skipped") {
|
|
@@ -38,7 +65,7 @@ export function JUnit(path: string): Reporter {
|
|
|
38
65
|
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
39
66
|
`<testsuite name="niceeval" tests="${summary.results.length}" failures="${summary.failed}" errors="${summary.errored}" skipped="${summary.skipped}" time="${(summary.durationMs / 1000).toFixed(3)}">\n` +
|
|
40
67
|
`${cases}\n</testsuite>\n`;
|
|
41
|
-
await
|
|
68
|
+
await atomicWriteFile(path, xml);
|
|
42
69
|
},
|
|
43
70
|
};
|
|
44
71
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// 改一个符号要同步三处且不报错。
|
|
1
|
+
// verdict 符号表,当前唯一消费方是 src/runner/feedback/human.ts 的 dashboard/完成页。
|
|
3
2
|
|
|
4
3
|
import type { Verdict } from "../../types.ts";
|
|
5
4
|
|
|
@@ -13,6 +12,3 @@ export const VERDICT_SYM: Record<Verdict, string> = {
|
|
|
13
12
|
export function verdictSymbol(verdict: string): string {
|
|
14
13
|
return VERDICT_SYM[verdict as Verdict] ?? "?";
|
|
15
14
|
}
|
|
16
|
-
|
|
17
|
-
/** live 表格里「还没抢到并发名额」的行:和转圈的 SPINNER、完成后的 VERDICT_SYM 三态区分开。 */
|
|
18
|
-
export const WAITING_SYM = "·";
|