niceeval 0.8.1 → 0.9.0
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/INDEX.md +77 -45
- package/dist/agents/types.d.ts +28 -6
- package/dist/i18n/en.d.ts +2 -0
- package/dist/i18n/zh-CN.d.ts +2 -0
- package/dist/report/built-in/index.d.ts +3 -2
- package/dist/report/built-in/index.js +7 -8
- package/dist/report/built-in/standard.d.ts +1 -0
- package/dist/report/built-in/standard.js +30 -0
- package/dist/report/components.d.ts +69 -2
- package/dist/report/components.js +152 -3
- package/dist/report/compute.d.ts +28 -1
- package/dist/report/compute.js +123 -0
- package/dist/report/index.d.ts +4 -4
- package/dist/report/index.js +3 -2
- package/dist/report/locale.d.ts +39 -1
- package/dist/report/locale.js +69 -0
- package/dist/report/react/AttemptList.d.ts +3 -1
- package/dist/report/react/AttemptList.js +3 -3
- package/dist/report/react/CopyFixPrompt.d.ts +12 -0
- package/dist/report/react/CopyFixPrompt.js +12 -0
- package/dist/report/react/HeroCard.d.ts +13 -0
- package/dist/report/react/HeroCard.js +35 -0
- package/dist/report/react/PoweredBy.d.ts +5 -0
- package/dist/report/react/PoweredBy.js +7 -0
- package/dist/report/react/ScopeWarnings.d.ts +12 -0
- package/dist/report/react/ScopeWarnings.js +18 -0
- package/dist/report/react/TraceWaterfall.d.ts +14 -0
- package/dist/report/react/TraceWaterfall.js +22 -0
- package/dist/report/react/index.d.ts +6 -1
- package/dist/report/react/index.js +6 -0
- package/dist/report/report.d.ts +22 -6
- package/dist/report/report.js +66 -53
- package/dist/report/scope-warnings.d.ts +28 -0
- package/dist/report/scope-warnings.js +101 -0
- package/dist/report/text/faces.d.ts +19 -1
- package/dist/report/text/faces.js +61 -0
- package/dist/report/tree.js +6 -1
- package/dist/report/types.d.ts +45 -0
- package/dist/report/web.d.ts +5 -4
- package/dist/report/web.js +7 -22
- package/dist/results/select.d.ts +15 -2
- package/dist/results/select.js +75 -10
- package/dist/results/types.d.ts +26 -0
- package/dist/runner/fingerprint.d.ts +3 -3
- package/dist/runner/sandbox-selection.d.ts +12 -0
- package/dist/runner/types.d.ts +18 -6
- package/dist/sandbox/types.d.ts +12 -0
- package/docs-site/zh/explanation/evals.mdx +2 -1
- package/docs-site/zh/explanation/experiment.mdx +2 -0
- package/docs-site/zh/how-to/custom-reports.mdx +22 -6
- package/docs-site/zh/how-to/publish-report.mdx +5 -4
- package/docs-site/zh/how-to/viewing-results.mdx +3 -3
- package/docs-site/zh/how-to/write-experiment.mdx +40 -1
- package/docs-site/zh/reference/builtin-agents.mdx +40 -3
- package/docs-site/zh/reference/cli.mdx +1 -1
- package/docs-site/zh/reference/define-eval.mdx +8 -0
- package/docs-site/zh/reference/official-adapters.mdx +10 -5
- package/package.json +2 -1
- package/src/agents/bub.ts +13 -1
- package/src/agents/claude-code.test.ts +43 -1
- package/src/agents/claude-code.ts +32 -14
- package/src/agents/codex.test.ts +168 -1
- package/src/agents/codex.ts +51 -15
- package/src/agents/mcp.ts +31 -0
- package/src/agents/post-setup.ts +33 -0
- package/src/agents/types.ts +28 -7
- package/src/cli.ts +8 -6
- package/src/define.ts +3 -0
- package/src/i18n/en.ts +5 -2
- package/src/i18n/zh-CN.ts +5 -1
- package/src/index.ts +1 -0
- package/src/report/built-in/index.tsx +8 -7
- package/src/report/built-in/standard.tsx +59 -0
- package/src/report/components.tsx +218 -2
- package/src/report/compute.ts +138 -1
- package/src/report/dual-render.test.tsx +139 -12
- package/src/report/index.ts +20 -0
- package/src/report/locale.ts +83 -1
- package/src/report/react/AttemptList.tsx +13 -1
- package/src/report/react/CopyFixPrompt.tsx +37 -0
- package/src/report/react/HeroCard.tsx +59 -0
- package/src/report/react/PoweredBy.tsx +20 -0
- package/src/report/react/ScopeWarnings.tsx +74 -0
- package/src/report/react/TraceWaterfall.tsx +78 -0
- package/src/report/react/enhance.js +14 -0
- package/src/report/react/index.tsx +11 -0
- package/src/report/react/styles.css +187 -7
- package/src/report/report.ts +97 -62
- package/src/report/scope-warnings.ts +155 -0
- package/src/report/site-components.test.tsx +526 -0
- package/src/report/text/faces.ts +66 -0
- package/src/report/tree.ts +8 -1
- package/src/report/types.ts +51 -0
- package/src/report/web.ts +7 -40
- package/src/results/host-equivalence.test.ts +5 -1
- package/src/results/open.ts +5 -4
- package/src/results/results.test.ts +78 -1
- package/src/results/select.ts +78 -10
- package/src/results/types.ts +27 -0
- package/src/runner/attempt.ts +10 -9
- package/src/runner/discover.test.ts +9 -1
- package/src/runner/discover.ts +3 -3
- package/src/runner/fingerprint.ts +9 -4
- package/src/runner/ledger.test.ts +30 -1
- package/src/runner/ledger.ts +26 -4
- package/src/runner/run.ts +5 -1
- package/src/runner/sandbox-selection.test.ts +131 -0
- package/src/runner/sandbox-selection.ts +110 -0
- package/src/runner/types.ts +19 -2
- package/src/sandbox/types.ts +6 -0
- package/src/show/index.ts +17 -10
- package/src/show/render.ts +11 -11
- package/src/show/report-host.test.ts +32 -15
- package/src/show/report-host.ts +5 -4
- package/src/show/show.test.ts +140 -3
- package/src/view/app/App.test.tsx +78 -17
- package/src/view/app/App.tsx +17 -78
- package/src/view/app/components/CopyControls.tsx +4 -42
- package/src/view/app/i18n.ts +5 -227
- package/src/view/app/lib/rows.ts +3 -21
- package/src/view/app/shared.ts +1 -3
- package/src/view/app/types.ts +2 -2
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +20 -20
- package/src/view/data.ts +2 -2
- package/src/view/shared/types.ts +11 -6
- package/src/view/styles.css +6 -266
- package/src/view/view-report.test.ts +59 -22
- package/src/view/app/components/LazyArtifact.tsx +0 -51
- package/src/view/app/components/SkippedRunsBanner.tsx +0 -140
- package/src/view/app/pages/AttemptsPage.tsx +0 -80
- package/src/view/app/pages/TracesPage.tsx +0 -35
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
// cases: docs/engineering/unit-tests/reports/cases.md
|
|
2
|
+
// 站点组件的单元测试:Hero / HeroCard(标题同源、显式覆盖、手写等价、合成来源标注、空 Scope)、
|
|
3
|
+
// PoweredBy(品牌行 href/rel、text 面零输出)、ScopeWarnings(按动作聚合、组排序与未知 kind
|
|
4
|
+
// 回退、汇总行、明细折叠、下一步随行、空集零输出)、CopyFixPrompt(prompt 内容、烘进静态
|
|
5
|
+
// HTML、全 passed 零输出、text 面恒零输出)、TraceWaterfall(两面、缺 trace、生命周期节点
|
|
6
|
+
// 不进瀑布)、AttemptList filter(渐进增强不改数据)。全部用内存 fake。
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
import type { AssertionResult, EvalResult, TraceSpan, Verdict } from "../types.ts";
|
|
11
|
+
import type { AttemptHandle, Results, Scope, ScopeWarning, Snapshot } from "../results/index.ts";
|
|
12
|
+
import { makeScope } from "../results/select.ts";
|
|
13
|
+
import {
|
|
14
|
+
createTextContext,
|
|
15
|
+
defineComponent,
|
|
16
|
+
renderNodeToText,
|
|
17
|
+
resolveReportTree,
|
|
18
|
+
validateReportTree,
|
|
19
|
+
ResolveMemo,
|
|
20
|
+
type ReportNode,
|
|
21
|
+
} from "./tree.ts";
|
|
22
|
+
import { buildReportMeta, defineReport, pickReportPage, renderReportToText, reportTitleText } from "./report.ts";
|
|
23
|
+
import { renderReportToStaticHtml } from "./web.ts";
|
|
24
|
+
import {
|
|
25
|
+
AttemptList,
|
|
26
|
+
CopyFixPrompt,
|
|
27
|
+
Hero,
|
|
28
|
+
HeroCard,
|
|
29
|
+
PoweredBy,
|
|
30
|
+
ScopeWarnings,
|
|
31
|
+
TraceWaterfall,
|
|
32
|
+
} from "./components.tsx";
|
|
33
|
+
import { Text } from "./primitives.tsx";
|
|
34
|
+
import { attemptListData, copyFixPromptData, heroData, traceWaterfallData } from "./compute.ts";
|
|
35
|
+
|
|
36
|
+
// ───────────────────────── fake 数据(按 results 读取契约造)─────────────────────────
|
|
37
|
+
|
|
38
|
+
let seq = 0;
|
|
39
|
+
|
|
40
|
+
function res(id: string, verdict: Verdict, extra: Partial<EvalResult> = {}): EvalResult {
|
|
41
|
+
seq += 1;
|
|
42
|
+
return {
|
|
43
|
+
id,
|
|
44
|
+
agent: "agent-x",
|
|
45
|
+
verdict,
|
|
46
|
+
attempt: 0,
|
|
47
|
+
startedAt: `2026-07-01T00:00:00.${String(seq).padStart(6, "0")}Z`,
|
|
48
|
+
durationMs: 1000,
|
|
49
|
+
assertions: [],
|
|
50
|
+
...extra,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let runSeq = 0;
|
|
55
|
+
|
|
56
|
+
/** 最小快照构造;traces 按 eval id 提供 trace artifact(缺省 null,与真实懒加载语义一致)。 */
|
|
57
|
+
function snap(spec: {
|
|
58
|
+
experimentId: string;
|
|
59
|
+
results: EvalResult[];
|
|
60
|
+
name?: Snapshot["name"];
|
|
61
|
+
runStartedAt?: string;
|
|
62
|
+
traces?: Record<string, TraceSpan[]>;
|
|
63
|
+
}): Snapshot {
|
|
64
|
+
runSeq += 1;
|
|
65
|
+
const startedAt = spec.runStartedAt ?? `2026-06-01T00:00:00.${String(runSeq).padStart(3, "0")}Z`;
|
|
66
|
+
const snapshot = {
|
|
67
|
+
experimentId: spec.experimentId,
|
|
68
|
+
startedAt,
|
|
69
|
+
completedAt: startedAt,
|
|
70
|
+
agent: "agent-x",
|
|
71
|
+
name: spec.name,
|
|
72
|
+
schemaVersion: 1,
|
|
73
|
+
dir: `/results/exp/snap-${runSeq}`,
|
|
74
|
+
} as Snapshot;
|
|
75
|
+
const attempts: AttemptHandle[] = spec.results.map((r) => ({
|
|
76
|
+
evalId: r.id,
|
|
77
|
+
experimentId: spec.experimentId,
|
|
78
|
+
result: r,
|
|
79
|
+
ref: { snapshot: `exp/snap-${runSeq}`, attempt: `${r.id}/a${r.attempt}` },
|
|
80
|
+
snapshot,
|
|
81
|
+
events: async () => null,
|
|
82
|
+
trace: async () => spec.traces?.[r.id] ?? null,
|
|
83
|
+
o11y: async () => null,
|
|
84
|
+
agentSetup: async () => null,
|
|
85
|
+
diff: async () => null,
|
|
86
|
+
sources: async () => null,
|
|
87
|
+
}));
|
|
88
|
+
const evals = new Map<string, AttemptHandle[]>();
|
|
89
|
+
for (const attempt of attempts) evals.set(attempt.evalId, [...(evals.get(attempt.evalId) ?? []), attempt]);
|
|
90
|
+
snapshot.evals = [...evals.entries()].map(([id, list]) => ({ id, attempts: list }));
|
|
91
|
+
snapshot.attempts = attempts;
|
|
92
|
+
return snapshot;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function scopeOf(snapshots: Snapshot[], warnings: ScopeWarning[] = []): Scope {
|
|
96
|
+
return makeScope("current-evals", snapshots, warnings);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resultsOf(snapshots: Snapshot[]): Results {
|
|
100
|
+
const byId = new Map<string, Snapshot[]>();
|
|
101
|
+
for (const s of snapshots) byId.set(s.experimentId, [...(byId.get(s.experimentId) ?? []), s]);
|
|
102
|
+
const experiments = [...byId.entries()].map(([id, snaps]) => {
|
|
103
|
+
const sorted = [...snaps].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
104
|
+
return {
|
|
105
|
+
id,
|
|
106
|
+
snapshots: sorted,
|
|
107
|
+
latest: sorted[0]!,
|
|
108
|
+
evalIds: [...new Set(sorted.flatMap((s) => s.evals.map((e) => e.id)))].sort(),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
experiments,
|
|
113
|
+
skipped: [],
|
|
114
|
+
latest: () => makeScope("latest-snapshots", experiments.map((e) => e.latest), []),
|
|
115
|
+
current: () => makeScope("current-evals", experiments.map((e) => e.latest), []),
|
|
116
|
+
} as unknown as Results;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hostCtx(scope: Scope) {
|
|
120
|
+
return { scope, results: resultsOf(scope.snapshots) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 管线便捷入口:装载 → resolve → validate → text 渲染(与 show 同一条管线,不带宿主前置块)。 */
|
|
124
|
+
async function treeText(node: ReportNode, scope: Scope, width = 120): Promise<string> {
|
|
125
|
+
const definition = defineReport(node);
|
|
126
|
+
const resolved = await resolveReportTree(pickReportPage(definition).content, {
|
|
127
|
+
scope,
|
|
128
|
+
results: resultsOf(scope.snapshots),
|
|
129
|
+
report: buildReportMeta(definition, scope, "report"),
|
|
130
|
+
memo: new ResolveMemo(),
|
|
131
|
+
});
|
|
132
|
+
validateReportTree(resolved);
|
|
133
|
+
return renderNodeToText(resolved, createTextContext({ width }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 管线便捷入口:web 面静态 HTML(经 renderReportToStaticHtml;测试用无警告 Scope,免宿主前置块)。 */
|
|
137
|
+
async function treeHtml(node: ReportNode, scope: Scope): Promise<string> {
|
|
138
|
+
return renderReportToStaticHtml(defineReport(node), hostCtx(scope));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ───────────────────────── 警告 fixture(按 ScopeWarning 联合造)─────────────────────────
|
|
142
|
+
|
|
143
|
+
function partialCoverage(id: string): ScopeWarning {
|
|
144
|
+
return {
|
|
145
|
+
kind: "partial-coverage",
|
|
146
|
+
experimentId: id,
|
|
147
|
+
covered: 4,
|
|
148
|
+
total: 6,
|
|
149
|
+
message: `snapshot "${id}" covers 4 of 6 known evals; re-run \`niceeval exp ${id}\` to fill the gap`,
|
|
150
|
+
command: `niceeval exp ${id}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function staleSnapshot(id: string): ScopeWarning {
|
|
155
|
+
return {
|
|
156
|
+
kind: "stale-snapshot",
|
|
157
|
+
experimentId: id,
|
|
158
|
+
startedAt: "2026-07-10T00:00:00Z",
|
|
159
|
+
latestStartedAt: "2026-07-12T00:00:00Z",
|
|
160
|
+
message: `verdicts for "${id}" were produced at 2026-07-10T00:00:00Z, 2 days before the latest run in this scope; re-run \`niceeval exp ${id}\` to align, or ignore if evals, agent and model are unchanged between the runs`,
|
|
161
|
+
command: `niceeval exp ${id}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function unfinishedSnapshot(id: string): ScopeWarning {
|
|
166
|
+
return {
|
|
167
|
+
kind: "unfinished-snapshot",
|
|
168
|
+
experimentId: id,
|
|
169
|
+
startedAt: "2026-07-11T00:00:00Z",
|
|
170
|
+
dir: `/results/${id}`,
|
|
171
|
+
message: `snapshot "${id}" (2026-07-11T00:00:00Z) is unfinished (the process was interrupted); completed attempts are read as-is, but the set may be incomplete — re-run \`niceeval exp ${id}\` for a complete snapshot`,
|
|
172
|
+
command: `niceeval exp ${id}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ───────────────────────── Hero 与 HeroCard ─────────────────────────
|
|
177
|
+
|
|
178
|
+
describe("Hero 与 HeroCard", () => {
|
|
179
|
+
const heroScope = () =>
|
|
180
|
+
scopeOf([
|
|
181
|
+
snap({ experimentId: "exp/a", results: [res("q1", "passed")], runStartedAt: "2026-07-01T10:00:00Z" }),
|
|
182
|
+
snap({ experimentId: "exp/b", results: [res("q1", "failed")], runStartedAt: "2026-07-03T10:00:00Z" }),
|
|
183
|
+
snap({ experimentId: "exp/c", results: [res("q2", "passed")], runStartedAt: "2026-07-02T10:00:00Z" }),
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
it("声明 title 后 <Hero /> 两面输出含该标题,且与浏览器标题(回退链)同源", async () => {
|
|
187
|
+
const scope = heroScope();
|
|
188
|
+
const definition = defineReport({ title: { en: "Memory Evals" }, content: <Hero /> });
|
|
189
|
+
const browserTitle = reportTitleText(definition, scope, "en");
|
|
190
|
+
expect(browserTitle).toBe("Memory Evals");
|
|
191
|
+
const text = await renderReportToText(definition, hostCtx(scope));
|
|
192
|
+
const html = await renderReportToStaticHtml(definition, hostCtx(scope));
|
|
193
|
+
expect(text).toContain("Memory Evals");
|
|
194
|
+
expect(html).toContain("<h1");
|
|
195
|
+
expect(html).toContain("Memory Evals");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("显式 title prop 覆盖站点声明", async () => {
|
|
199
|
+
const scope = heroScope();
|
|
200
|
+
const definition = defineReport({ title: "Declared Title", content: <Hero title="Custom Hero" /> });
|
|
201
|
+
const text = await renderReportToText(definition, hostCtx(scope));
|
|
202
|
+
const html = await renderReportToStaticHtml(definition, hostCtx(scope));
|
|
203
|
+
expect(text).toContain("Custom Hero");
|
|
204
|
+
expect(text).not.toContain("Declared Title");
|
|
205
|
+
expect(html).toContain("Custom Hero");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("<Hero /> 与手写 <HeroCard title={ctx.report.title} data={await heroData(ctx.scope)} /> 严格等价", async () => {
|
|
209
|
+
const scope = heroScope();
|
|
210
|
+
const Handwritten = defineComponent(async (_props: Record<never, never>, ctx) => (
|
|
211
|
+
<HeroCard title={ctx.report.title} data={await heroData(ctx.scope)} />
|
|
212
|
+
));
|
|
213
|
+
const [heroText, handText] = [await treeText(<Hero />, scope), await treeText(<Handwritten />, scope)];
|
|
214
|
+
expect(heroText).toBe(handText);
|
|
215
|
+
const [heroHtml, handHtml] = [await treeHtml(<Hero />, scope), await treeHtml(<Handwritten />, scope)];
|
|
216
|
+
expect(heroHtml).toBe(handHtml);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("heroData:latestStartedAt 取范围内最新快照开始时间、snapshots 计贡献快照数;web 面标注合成来源", async () => {
|
|
220
|
+
const scope = heroScope();
|
|
221
|
+
const data = await heroData(scope);
|
|
222
|
+
expect(data.latestStartedAt).toBe("2026-07-03T10:00:00Z");
|
|
223
|
+
expect(data.snapshots).toBe(3);
|
|
224
|
+
const html = await treeHtml(<HeroCard title="T" data={data} />, scope);
|
|
225
|
+
expect(html).toContain("composed from 3 runs");
|
|
226
|
+
// text 面的合成来源标注(show 页首 meta 行)
|
|
227
|
+
const text = await treeText(<HeroCard title="T" data={data} />, scope);
|
|
228
|
+
expect(text).toContain("composed from 3 snapshots");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("空 Scope:latestStartedAt 为 null,两面显示内置「暂无运行」文案,不编造当前时间", async () => {
|
|
232
|
+
const empty = scopeOf([]);
|
|
233
|
+
const data = await heroData(empty);
|
|
234
|
+
expect(data).toEqual({ latestStartedAt: null, snapshots: 0 });
|
|
235
|
+
const html = await treeHtml(<HeroCard title="T" data={data} />, empty);
|
|
236
|
+
expect(html).toContain("No runs yet");
|
|
237
|
+
const text = await treeText(<HeroCard title="T" data={data} />, empty);
|
|
238
|
+
expect(text).toContain("No runs yet");
|
|
239
|
+
expect(text).not.toContain("202"); // 没有任何编造的时间
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ───────────────────────── 品牌组件 ─────────────────────────
|
|
244
|
+
|
|
245
|
+
describe("品牌是组件(PoweredBy / HeroCard 品牌行)", () => {
|
|
246
|
+
const scope = () => scopeOf([snap({ experimentId: "exp/a", results: [res("q1", "passed")] })]);
|
|
247
|
+
|
|
248
|
+
it("PoweredBy web 面渲染官网品牌行:href 含 utm_source=report&utm_medium=powered-by,rel 仅 noopener", async () => {
|
|
249
|
+
const html = await treeHtml(<PoweredBy />, scope());
|
|
250
|
+
expect(html).toContain("Powered by NiceEval");
|
|
251
|
+
expect(html).toContain("https://niceeval.com/?utm_source=report&utm_medium=powered-by");
|
|
252
|
+
expect(html).toContain('rel="noopener"');
|
|
253
|
+
expect(html).not.toContain("noreferrer");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("PoweredBy text 面零输出;HeroCard web 面恒含品牌行、text 面不含", async () => {
|
|
257
|
+
const s = scope();
|
|
258
|
+
expect(await treeText(<PoweredBy />, s)).toBe("");
|
|
259
|
+
const data = await heroData(s);
|
|
260
|
+
const html = await treeHtml(<HeroCard title="T" data={data} />, s);
|
|
261
|
+
expect(html).toContain("nre-powered-by");
|
|
262
|
+
expect(html).toContain("Powered by NiceEval");
|
|
263
|
+
const text = await treeText(<HeroCard title="T" data={data} />, s);
|
|
264
|
+
expect(text).not.toContain("Powered by");
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ───────────────────────── ScopeWarnings ─────────────────────────
|
|
269
|
+
|
|
270
|
+
describe("ScopeWarnings(按动作聚合)", () => {
|
|
271
|
+
const plainScope = () => scopeOf([snap({ experimentId: "exp/a", results: [res("q1", "passed")] })]);
|
|
272
|
+
|
|
273
|
+
it("同 experimentId 的多 kind 聚合为一组:组头含实验 id、两枚徽标与去重后恰一条命令;不同实验不进同一组", async () => {
|
|
274
|
+
const warnings = [partialCoverage("exp/a"), staleSnapshot("exp/a"), partialCoverage("exp/b")];
|
|
275
|
+
const html = await treeHtml(<ScopeWarnings data={warnings} />, plainScope());
|
|
276
|
+
expect(html.match(/class="nre-warning-group"/g)?.length).toBe(2);
|
|
277
|
+
// exp/a 组:coverage 徽标 + gap 徽标齐全
|
|
278
|
+
expect(html).toContain("coverage 4/6");
|
|
279
|
+
expect(html).toContain("2 days behind");
|
|
280
|
+
// 组头命令去重后恰一条(exp/a 的两条警告命令相同)
|
|
281
|
+
expect(html.match(/data-nre-copy="niceeval exp exp\/a"/g)?.length).toBe(1);
|
|
282
|
+
expect(html.match(/data-nre-copy="niceeval exp exp\/b"/g)?.length).toBe(1);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("组排序:integrity 组在 freshness 组之前,混合组按最重成员归位;未知 kind 单独成组、message 原样、按 integrity 归位", async () => {
|
|
286
|
+
const unknown = {
|
|
287
|
+
kind: "future-kind",
|
|
288
|
+
message: "something new happened; check the docs for future-kind",
|
|
289
|
+
} as unknown as ScopeWarning;
|
|
290
|
+
// 声明顺序故意把 freshness(仅 stale 的实验)放最前
|
|
291
|
+
const warnings = [staleSnapshot("exp/fresh"), partialCoverage("exp/int"), unknown];
|
|
292
|
+
const html = await treeHtml(<ScopeWarnings data={warnings} />, plainScope());
|
|
293
|
+
const posInt = html.indexOf("exp/int");
|
|
294
|
+
const posUnknown = html.indexOf("future-kind");
|
|
295
|
+
const posFresh = html.indexOf("exp/fresh");
|
|
296
|
+
expect(posInt).toBeGreaterThan(-1);
|
|
297
|
+
expect(posUnknown).toBeGreaterThan(-1);
|
|
298
|
+
// integrity(exp/int 与未知 kind)都排在仅 stale 的 freshness 组之前
|
|
299
|
+
expect(posInt).toBeLessThan(posFresh);
|
|
300
|
+
expect(posUnknown).toBeLessThan(posFresh);
|
|
301
|
+
// 未知 kind 的 message 完整可见
|
|
302
|
+
expect(html).toContain("something new happened; check the docs for future-kind");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("web 面整个警告区默认折叠:外层 <details> 无 open,<summary> 是分类计数汇总行且任何组数下都渲染;text 面单组无汇总行", async () => {
|
|
306
|
+
const two = await treeHtml(<ScopeWarnings data={[partialCoverage("exp/a"), staleSnapshot("exp/b")]} />, plainScope());
|
|
307
|
+
expect(two).toContain('<details class="nre-warnings">');
|
|
308
|
+
expect(two).toContain('<summary class="nre-warnings-summary">2 experiments flagged</summary>');
|
|
309
|
+
const one = await treeHtml(<ScopeWarnings data={[partialCoverage("exp/a")]} />, plainScope());
|
|
310
|
+
expect(one).toContain('<details class="nre-warnings">');
|
|
311
|
+
expect(one).toContain('<summary class="nre-warnings-summary">1 experiment flagged</summary>');
|
|
312
|
+
// text 面汇总行只在多组时输出:单组首行即组头
|
|
313
|
+
const oneText = await treeText(<ScopeWarnings data={[partialCoverage("exp/a")]} />, plainScope(), 200);
|
|
314
|
+
expect(oneText.split("\n")[0]).toContain("exp/a");
|
|
315
|
+
expect(oneText).not.toContain("experiment flagged");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("明细折叠:web 面组级 <details> 在总条数 ≤ 3 时默认展开、4 条时不展开,外层恒无 open;text 面不折叠,组头一行 + 缩进逐条 message 以下一步收尾", async () => {
|
|
319
|
+
const three = [partialCoverage("exp/a"), staleSnapshot("exp/a"), partialCoverage("exp/b")];
|
|
320
|
+
const four = [...three, unfinishedSnapshot("exp/b")];
|
|
321
|
+
const htmlThree = await treeHtml(<ScopeWarnings data={three} />, plainScope());
|
|
322
|
+
const htmlFour = await treeHtml(<ScopeWarnings data={four} />, plainScope());
|
|
323
|
+
// 外层警告区两种条数下都默认折叠
|
|
324
|
+
expect(htmlThree).toContain('<details class="nre-warnings">');
|
|
325
|
+
expect(htmlFour).toContain('<details class="nre-warnings">');
|
|
326
|
+
expect(htmlThree).toMatch(/class="nre-warning-details"[^>]*\sopen/);
|
|
327
|
+
expect(htmlFour).not.toMatch(/<details[^>]*\sopen/);
|
|
328
|
+
|
|
329
|
+
const text = await treeText(<ScopeWarnings data={[partialCoverage("exp/a"), staleSnapshot("exp/a")]} />, plainScope(), 200);
|
|
330
|
+
const lines = text.split("\n");
|
|
331
|
+
// 组头一行:标题 + 徽标 + 组头命令
|
|
332
|
+
expect(lines[0]).toContain("exp/a");
|
|
333
|
+
expect(lines[0]).toContain("coverage 4/6");
|
|
334
|
+
expect(lines[0]).toContain("niceeval exp exp/a");
|
|
335
|
+
// 明细缩进逐条原样打印,不截断掉尾段(stale 的 message 以忽略条件收尾)
|
|
336
|
+
expect(lines.some((line) => line.startsWith("! ") && line.endsWith("to fill the gap"))).toBe(true);
|
|
337
|
+
expect(lines.some((line) => line.startsWith("! ") && line.endsWith("unchanged between the runs"))).toBe(true);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("下一步随行:带 command 的条目在 web 面可复制,无 command 的不硬造动作;空集与裸 Snapshot[] 输入两面零输出", async () => {
|
|
341
|
+
const scope = plainScope();
|
|
342
|
+
const staleHtml = await treeHtml(<ScopeWarnings data={[staleSnapshot("exp/a")]} />, scope);
|
|
343
|
+
expect(staleHtml).toContain('data-nre-copy="niceeval exp exp/a"');
|
|
344
|
+
// missing-startedAt 形态(无 command)的条目:message 可见、无复制动作
|
|
345
|
+
const noCommand = {
|
|
346
|
+
kind: "missing-startedAt",
|
|
347
|
+
experimentId: "exp/a",
|
|
348
|
+
evalId: "q1",
|
|
349
|
+
message: "attempt identity for exp/a q1 lacks startedAt; check the writer that produced it",
|
|
350
|
+
} as unknown as ScopeWarning;
|
|
351
|
+
const noCommandHtml = await treeHtml(<ScopeWarnings data={[noCommand]} />, scope);
|
|
352
|
+
expect(noCommandHtml).toContain("lacks startedAt");
|
|
353
|
+
expect(noCommandHtml).not.toContain("data-nre-copy");
|
|
354
|
+
expect(noCommandHtml).not.toContain("nre-warning-command");
|
|
355
|
+
// 空警告集:两面零输出,不渲染空容器
|
|
356
|
+
expect(await treeHtml(<ScopeWarnings data={[]} />, scope)).toBe("");
|
|
357
|
+
expect(await treeText(<ScopeWarnings data={[]} />, scope)).toBe("");
|
|
358
|
+
// 裸 Snapshot[] 输入:没有挑选过程、没有警告,渲染为空
|
|
359
|
+
expect(await treeHtml(<ScopeWarnings input={scope.snapshots} />, scope)).toBe("");
|
|
360
|
+
expect(await treeText(<ScopeWarnings input={scope.snapshots} />, scope)).toBe("");
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ───────────────────────── CopyFixPrompt ─────────────────────────
|
|
365
|
+
|
|
366
|
+
describe("CopyFixPrompt", () => {
|
|
367
|
+
const failedRes = () =>
|
|
368
|
+
res("fix/failed", "failed", {
|
|
369
|
+
assertions: [
|
|
370
|
+
{
|
|
371
|
+
name: "equals",
|
|
372
|
+
severity: "gate",
|
|
373
|
+
outcome: "failed" as const,
|
|
374
|
+
score: 0,
|
|
375
|
+
detail: "equals(42)",
|
|
376
|
+
expected: "42",
|
|
377
|
+
received: "41",
|
|
378
|
+
},
|
|
379
|
+
] as AssertionResult[],
|
|
380
|
+
});
|
|
381
|
+
const erroredRes = () =>
|
|
382
|
+
res("fix/errored", "errored", {
|
|
383
|
+
error: { code: "sandbox-create-failed", message: "docker daemon unreachable", phase: "sandbox.create" },
|
|
384
|
+
});
|
|
385
|
+
const failingScope = () =>
|
|
386
|
+
scopeOf([snap({ experimentId: "exp/a", results: [failedRes(), erroredRes(), res("fix/passed", "passed")] })]);
|
|
387
|
+
|
|
388
|
+
it("两失败 fixture 的 prompt 含 eval id、主失败摘要与 attempt 下钻命令,并在 resolve 期烘进静态 HTML(无 JS 折叠块完整可读)", async () => {
|
|
389
|
+
const scope = failingScope();
|
|
390
|
+
const data = await copyFixPromptData(scope);
|
|
391
|
+
expect(data.failures).toBe(2);
|
|
392
|
+
expect(data.prompt).toContain('eval "fix/failed"');
|
|
393
|
+
expect(data.prompt).toContain('eval "fix/errored"');
|
|
394
|
+
expect(data.prompt).toContain("equals(42)");
|
|
395
|
+
expect(data.prompt).toContain("docker daemon unreachable");
|
|
396
|
+
expect(data.prompt).toMatch(/inspect: niceeval show @1[0-9a-z]{7}/);
|
|
397
|
+
// spec 形态经管线烘进静态 HTML:prompt 全文在折叠块里,复制走增强层(data-nre-copy)
|
|
398
|
+
const html = await treeHtml(<CopyFixPrompt />, scope);
|
|
399
|
+
expect(html).toContain("<details");
|
|
400
|
+
expect(html).toContain("equals(42)");
|
|
401
|
+
expect(html).toContain("Fix the failing evals from this niceeval run.");
|
|
402
|
+
expect(html).toContain("data-nre-copy");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("全 passed 时两面零输出,不渲染任何节点", async () => {
|
|
406
|
+
const scope = scopeOf([snap({ experimentId: "exp/a", results: [res("q1", "passed"), res("q2", "passed")] })]);
|
|
407
|
+
expect(await treeHtml(<CopyFixPrompt />, scope)).toBe("");
|
|
408
|
+
expect(await treeText(<CopyFixPrompt />, scope)).toBe("");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("text 面恒零输出:show 输出不含 prompt 文本", async () => {
|
|
412
|
+
const scope = failingScope();
|
|
413
|
+
const definition = defineReport(
|
|
414
|
+
<>
|
|
415
|
+
<CopyFixPrompt />
|
|
416
|
+
<Text>after-prompt-marker</Text>
|
|
417
|
+
</>,
|
|
418
|
+
);
|
|
419
|
+
const text = await renderReportToText(definition, hostCtx(scope));
|
|
420
|
+
expect(text).toContain("after-prompt-marker");
|
|
421
|
+
expect(text).not.toContain("Fix the failing evals");
|
|
422
|
+
expect(text).not.toContain("inspect:");
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// ───────────────────────── TraceWaterfall ─────────────────────────
|
|
427
|
+
|
|
428
|
+
describe("TraceWaterfall", () => {
|
|
429
|
+
const spans: TraceSpan[] = [
|
|
430
|
+
// 故意乱序声明:验证按 startOffsetMs 升序
|
|
431
|
+
{ traceId: "t", spanId: "s2", name: "model call", startMs: 1500, endMs: 2500, kind: "model", status: "ok" },
|
|
432
|
+
{ traceId: "t", spanId: "s1", name: "turn 1", startMs: 1000, endMs: 3000, kind: "turn" },
|
|
433
|
+
{ traceId: "t", spanId: "s3", name: "tool: bash", startMs: 2600, endMs: 2900, kind: "tool", status: "error" },
|
|
434
|
+
// 子 span:不进顶层摘要
|
|
435
|
+
{ traceId: "t", spanId: "s4", parentSpanId: "s1", name: "nested child", startMs: 1100, endMs: 1200, kind: "tool" },
|
|
436
|
+
];
|
|
437
|
+
|
|
438
|
+
const traceScope = () =>
|
|
439
|
+
scopeOf([
|
|
440
|
+
snap({
|
|
441
|
+
experimentId: "exp/a",
|
|
442
|
+
results: [res("trace/with", "failed"), res("trace/without", "passed")],
|
|
443
|
+
traces: { "trace/with": spans },
|
|
444
|
+
}),
|
|
445
|
+
]);
|
|
446
|
+
|
|
447
|
+
it("两 attempt(一含失败 span)两面各自正确,spans 按 startOffsetMs 升序、只含顶层 span", async () => {
|
|
448
|
+
const scope = traceScope();
|
|
449
|
+
const rows = await traceWaterfallData(scope);
|
|
450
|
+
expect(rows).toHaveLength(2);
|
|
451
|
+
const withTrace = rows.find((r) => r.evalId === "trace/with")!;
|
|
452
|
+
expect(withTrace.durationMs).toBe(2000);
|
|
453
|
+
expect(withTrace.spans.map((s) => s.name)).toEqual(["turn 1", "model call", "tool: bash"]);
|
|
454
|
+
expect(withTrace.spans.map((s) => s.startOffsetMs)).toEqual([0, 500, 1600]);
|
|
455
|
+
expect(withTrace.spans.map((s) => s.kind)).toEqual(["agent", "model", "tool"]);
|
|
456
|
+
expect(withTrace.spans.map((s) => s.failed)).toEqual([false, false, true]);
|
|
457
|
+
|
|
458
|
+
const html = await treeHtml(<TraceWaterfall />, scope);
|
|
459
|
+
expect(html.match(/nre-waterfall-row/g)?.length).toBe(2);
|
|
460
|
+
expect(html).toContain("nre-span-failed");
|
|
461
|
+
expect(html).toContain(`href="#/attempt/${withTrace.locator}"`);
|
|
462
|
+
|
|
463
|
+
const text = await treeText(<TraceWaterfall />, scope, 200);
|
|
464
|
+
const lines = text.split("\n");
|
|
465
|
+
expect(lines).toHaveLength(2);
|
|
466
|
+
const withLine = lines.find((line) => line.includes("trace/with"))!;
|
|
467
|
+
expect(withLine).toContain(withTrace.locator);
|
|
468
|
+
expect(withLine).toContain("2.0s");
|
|
469
|
+
expect(withLine).toContain("3 spans");
|
|
470
|
+
expect(withLine).toContain("✗ 1 failed");
|
|
471
|
+
expect(withLine).toContain(`niceeval show ${withTrace.locator} --timing`);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("缺 trace.json 的 attempt:durationMs 为 null 且行不消失,证据位置如实显示缺失", async () => {
|
|
475
|
+
const scope = traceScope();
|
|
476
|
+
const rows = await traceWaterfallData(scope);
|
|
477
|
+
const without = rows.find((r) => r.evalId === "trace/without")!;
|
|
478
|
+
expect(without.durationMs).toBeNull();
|
|
479
|
+
expect(without.spans).toEqual([]);
|
|
480
|
+
const text = await treeText(<TraceWaterfall />, scope, 200);
|
|
481
|
+
const line = text.split("\n").find((l) => l.includes("trace/without"))!;
|
|
482
|
+
expect(line).toContain("no trace");
|
|
483
|
+
expect(line).toContain(`niceeval show ${without.locator} --timing`);
|
|
484
|
+
const html = await treeHtml(<TraceWaterfall />, scope);
|
|
485
|
+
expect(html).toContain("no trace");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("runner 生命周期节点不进瀑布行:span 事实只来自 trace artifact,phases 不被读取", async () => {
|
|
489
|
+
const withPhases = res("trace/phased", "passed", {
|
|
490
|
+
phases: [{ name: "sandbox.create", durationMs: 5000 }] as EvalResult["phases"],
|
|
491
|
+
});
|
|
492
|
+
const scope = scopeOf([
|
|
493
|
+
snap({ experimentId: "exp/a", results: [withPhases], traces: { "trace/phased": spans } }),
|
|
494
|
+
]);
|
|
495
|
+
const rows = await traceWaterfallData(scope);
|
|
496
|
+
expect(rows).toHaveLength(1);
|
|
497
|
+
expect(rows[0]!.spans.map((s) => s.name)).toEqual(["turn 1", "model call", "tool: bash"]);
|
|
498
|
+
expect(rows[0]!.spans.some((s) => s.name.includes("sandbox"))).toBe(false);
|
|
499
|
+
const html = await treeHtml(<TraceWaterfall />, scope);
|
|
500
|
+
expect(html).not.toContain("sandbox.create");
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// ───────────────────────── AttemptList filter ─────────────────────────
|
|
505
|
+
|
|
506
|
+
describe("AttemptList 的 filter 渐进增强", () => {
|
|
507
|
+
it("有无 filter 时初始行集合与 text 输出相同:filter 只加过滤框,不改变数据与 text 面", async () => {
|
|
508
|
+
const scope = scopeOf([
|
|
509
|
+
snap({
|
|
510
|
+
experimentId: "exp/a",
|
|
511
|
+
results: [res("q1", "passed"), res("q2", "failed"), res("q3", "errored", { error: { code: "x", message: "boom", phase: "eval.run" } })],
|
|
512
|
+
}),
|
|
513
|
+
]);
|
|
514
|
+
const items = await attemptListData(scope);
|
|
515
|
+
const plain = await treeHtml(<AttemptList data={items} />, scope);
|
|
516
|
+
const filtered = await treeHtml(<AttemptList data={items} filter />, scope);
|
|
517
|
+
// 过滤框是唯一差异;去掉它后行集合与初始 HTML 完全相同
|
|
518
|
+
expect(filtered).toContain("data-nre-attempt-filter");
|
|
519
|
+
expect(filtered.replace(/<input[^>]*data-nre-attempt-filter[^>]*>/, "")).toBe(plain);
|
|
520
|
+
expect(plain.match(/class="nre-attempt /g)?.length).toBe(3);
|
|
521
|
+
|
|
522
|
+
const textPlain = await treeText(<AttemptList data={items} />, scope, 160);
|
|
523
|
+
const textFiltered = await treeText(<AttemptList data={items} filter />, scope, 160);
|
|
524
|
+
expect(textFiltered).toBe(textPlain);
|
|
525
|
+
});
|
|
526
|
+
});
|
package/src/report/text/faces.ts
CHANGED
|
@@ -11,16 +11,21 @@ import type {
|
|
|
11
11
|
ExperimentComparisonData,
|
|
12
12
|
ExperimentComparisonGroupData,
|
|
13
13
|
ExperimentListItem,
|
|
14
|
+
HeroData,
|
|
14
15
|
LineData,
|
|
15
16
|
MatrixData,
|
|
16
17
|
MetricCell,
|
|
17
18
|
MetricColumn,
|
|
18
19
|
ScatterData,
|
|
19
20
|
ScopeSummaryData,
|
|
21
|
+
ScopeWarning,
|
|
20
22
|
ScoreboardData,
|
|
21
23
|
TableData,
|
|
24
|
+
TraceWaterfallRow,
|
|
22
25
|
VerdictTally,
|
|
23
26
|
} from "../types.ts";
|
|
27
|
+
import type { LocalizedText } from "../locale.ts";
|
|
28
|
+
import { groupScopeWarnings } from "../scope-warnings.ts";
|
|
24
29
|
import type { TextContext } from "../tree.ts";
|
|
25
30
|
import type { TableColumn, TableRow } from "../primitives.tsx";
|
|
26
31
|
import {
|
|
@@ -699,3 +704,64 @@ export function attemptListText(items: readonly AttemptListItem[], total: number
|
|
|
699
704
|
if (remaining > 0) blocks.push(localeText(locale, "attemptList.truncatedText", { n: remaining }));
|
|
700
705
|
return blocks.join("\n\n");
|
|
701
706
|
}
|
|
707
|
+
|
|
708
|
+
// ───────────────────────── 站点组件(HeroCard / ScopeWarnings / TraceWaterfall)─────────────────────────
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* HeroCard 的 text 面:标题行 + meta 行(最后运行时间;空范围为内置「暂无运行」文案;
|
|
712
|
+
* 多快照时标注合成来源),不含品牌行(品牌行是纯 web 件,text 面零输出)。
|
|
713
|
+
*/
|
|
714
|
+
export function heroCardText(title: LocalizedText, data: HeroData, ctx: TextContext): string {
|
|
715
|
+
const locale = ctx.locale;
|
|
716
|
+
const meta =
|
|
717
|
+
data.latestStartedAt === null
|
|
718
|
+
? localeText(locale, "hero.noRuns")
|
|
719
|
+
: [
|
|
720
|
+
localeText(locale, "hero.lastRun", { time: formatDateTimeMinute(data.latestStartedAt) }),
|
|
721
|
+
...(data.snapshots > 1 ? [localeText(locale, "hero.composedSnapshots", { n: data.snapshots })] : []),
|
|
722
|
+
].join(" · ");
|
|
723
|
+
return `${resolveLocalizedText(title, locale)}\n${meta}`;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* ScopeWarnings 的 text 面:按动作聚合(../scope-warnings.ts,与 web 面共用),同构但不折叠——
|
|
728
|
+
* 多组时首行 "! <分类计数汇总>";每组一行组头 "! <标题> — <徽标> → <组头命令>",其下缩进
|
|
729
|
+
* 逐条原样打印 message(已以下一步收尾,不截断掉尾段)。空警告集零输出。
|
|
730
|
+
*/
|
|
731
|
+
export function scopeWarningsText(warnings: readonly ScopeWarning[], ctx: TextContext): string {
|
|
732
|
+
if (warnings.length === 0) return "";
|
|
733
|
+
const { summary, groups } = groupScopeWarnings(warnings, ctx.locale);
|
|
734
|
+
const lines: string[] = [];
|
|
735
|
+
// 汇总行只在多组时打印;单组时组头即汇总,不另起一行(web 面则恒以汇总行作外层 <summary>)。
|
|
736
|
+
if (groups.length > 1) lines.push(`! ${summary}`);
|
|
737
|
+
for (const group of groups) {
|
|
738
|
+
const badges = group.badges.length > 0 ? ` — ${group.badges.map((b) => b.text).join(" · ")}` : "";
|
|
739
|
+
const command = group.headCommand !== null ? ` → ${group.headCommand}` : "";
|
|
740
|
+
lines.push(`! ${group.title}${badges}${command}`);
|
|
741
|
+
for (const w of group.warnings) lines.push(`! ${w.message}`);
|
|
742
|
+
}
|
|
743
|
+
return lines.join("\n");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* TraceWaterfall 的 text 面:每 attempt 一行——locator、总耗时(缺 trace 如实显示缺失)、
|
|
748
|
+
* 顶层 span 计数与失败标记,行尾是可复制的 `--timing` 下钻命令(经宿主注入的
|
|
749
|
+
* attemptCommand 通道拼出,携带宿主上下文)。attempt 有选择器,索引终结于可执行命令。
|
|
750
|
+
*/
|
|
751
|
+
export function traceWaterfallText(rows: readonly TraceWaterfallRow[], ctx: TextContext): string {
|
|
752
|
+
const locale = ctx.locale;
|
|
753
|
+
if (rows.length === 0) return localeText(locale, "traceWaterfall.empty");
|
|
754
|
+
return rows
|
|
755
|
+
.map((row) => {
|
|
756
|
+
const failedSpans = row.spans.filter((span) => span.failed).length;
|
|
757
|
+
const parts = [
|
|
758
|
+
row.locator,
|
|
759
|
+
row.evalId,
|
|
760
|
+
row.durationMs === null ? localeText(locale, "traceWaterfall.noTrace") : formatDurationMs(row.durationMs),
|
|
761
|
+
countText(locale, "traceWaterfall.spans", row.spans.length),
|
|
762
|
+
...(failedSpans > 0 ? [`✗ ${countText(locale, "traceWaterfall.failedSpans", failedSpans)}`] : []),
|
|
763
|
+
];
|
|
764
|
+
return `${parts.join(" · ")} ${ctx.attemptCommand(row.locator)} --timing`;
|
|
765
|
+
})
|
|
766
|
+
.join("\n");
|
|
767
|
+
}
|
package/src/report/tree.ts
CHANGED
|
@@ -339,7 +339,14 @@ async function resolveNode(node: ReportNode | string | number, state: ResolveSta
|
|
|
339
339
|
return node as unknown as ReportNode;
|
|
340
340
|
}
|
|
341
341
|
if (Array.isArray(node)) {
|
|
342
|
-
|
|
342
|
+
const resolved = await Promise.all(node.map((child) => resolveNode(child as ReportNode, state, path)));
|
|
343
|
+
// resolve 重建的 children 数组对 React 是动态列表(JSX 静态 children 的免 key 待遇随重建
|
|
344
|
+
// 丢失);声明序即身份,给缺 key 的元素补声明位 key,免得 web 面渲染刷 key 警告。
|
|
345
|
+
return resolved.map((child, i) =>
|
|
346
|
+
isReportElement(child) && (child.key === undefined || child.key === null)
|
|
347
|
+
? ({ ...child, key: `.nre-${i}` } as ReportElement)
|
|
348
|
+
: child,
|
|
349
|
+
);
|
|
343
350
|
}
|
|
344
351
|
if (!isReportElement(node)) return node;
|
|
345
352
|
const { type, props } = node;
|