niceeval 0.4.5 → 0.5.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/docs-site/zh/concepts/adapter.mdx +6 -6
- package/docs-site/zh/concepts/experiment.mdx +7 -7
- package/docs-site/zh/concepts/overview.mdx +1 -1
- package/docs-site/zh/concepts/tier.mdx +6 -6
- package/docs-site/zh/guides/agent-feedback-loop.mdx +63 -20
- package/docs-site/zh/guides/connect-your-agent.mdx +7 -7
- package/docs-site/zh/guides/custom-reports.mdx +243 -0
- package/docs-site/zh/guides/experiments.mdx +3 -3
- package/docs-site/zh/guides/report-components.mdx +252 -0
- package/docs-site/zh/guides/reporters.mdx +1 -1
- package/docs-site/zh/guides/results-data.mdx +224 -0
- package/docs-site/zh/guides/runner.mdx +1 -1
- package/docs-site/zh/guides/sandbox-agent.mdx +2 -2
- package/docs-site/zh/guides/viewing-results.mdx +147 -77
- package/docs-site/zh/guides/write-experiment.mdx +9 -9
- package/docs-site/zh/guides/write-send.mdx +6 -6
- package/docs-site/zh/index.mdx +1 -1
- package/docs-site/zh/introduction.mdx +1 -1
- package/docs-site/zh/reference/cli.mdx +10 -2
- package/docs-site/zh/reference/define-agent.mdx +5 -5
- package/docs-site/zh/reference/define-config.mdx +1 -1
- package/docs-site/zh/reference/define-eval.mdx +3 -3
- package/package.json +3 -2
- package/src/agents/ai-sdk.test.ts +1 -1
- package/src/agents/ai-sdk.ts +2 -2
- package/src/agents/bub.ts +14 -4
- package/src/agents/claude-code.ts +1 -1
- package/src/agents/codex.ts +2 -2
- package/src/agents/streaming.test.ts +1 -1
- package/src/agents/types.ts +4 -4
- package/src/agents/ui-message-stream.test.ts +1 -1
- package/src/cli.ts +104 -14
- package/src/context/context.test.ts +1 -1
- package/src/context/context.ts +3 -3
- package/src/context/session.ts +2 -2
- package/src/context/types.ts +2 -2
- package/src/i18n/en.ts +29 -7
- package/src/i18n/zh-CN.ts +24 -2
- package/src/report/aggregate.ts +79 -50
- package/src/report/components.tsx +190 -0
- package/src/report/compute.ts +149 -84
- package/src/report/default-report.tsx +222 -0
- package/src/report/dual-face.test.tsx +527 -0
- package/src/report/format.ts +29 -0
- package/src/report/index.ts +79 -24
- package/src/report/load.ts +67 -0
- package/src/report/metrics.ts +2 -1
- package/src/report/param.ts +18 -0
- package/src/report/primitives.tsx +117 -0
- package/src/report/react/CaseList.tsx +2 -2
- package/src/report/react/DeltaTable.tsx +2 -2
- package/src/report/react/MetricBars.tsx +109 -0
- package/src/report/react/MetricLine.tsx +200 -0
- package/src/report/react/MetricMatrix.tsx +1 -1
- package/src/report/react/MetricScatter.tsx +1 -1
- package/src/report/react/MetricTable.tsx +1 -1
- package/src/report/react/RunOverview.tsx +4 -4
- package/src/report/react/Scoreboard.tsx +3 -3
- package/src/report/react/cell.tsx +1 -1
- package/src/report/react/fixtures.ts +98 -36
- package/src/report/react/format.ts +3 -28
- package/src/report/react/index.tsx +41 -24
- package/src/report/react/render.test.tsx +8 -8
- package/src/report/react/styles.css +77 -0
- package/src/report/report.test.ts +248 -128
- package/src/report/report.ts +69 -0
- package/src/report/text/faces.ts +356 -0
- package/src/report/text/layout.ts +125 -0
- package/src/report/text/plot.ts +127 -0
- package/src/report/tree.ts +220 -0
- package/src/report/types.ts +75 -27
- package/src/report/web.ts +40 -0
- package/src/results/copy.ts +95 -41
- package/src/results/format.ts +12 -11
- package/src/results/index.ts +34 -17
- package/src/results/open.ts +201 -81
- package/src/results/results.test.ts +595 -191
- package/src/results/select.ts +107 -55
- package/src/results/types.ts +137 -45
- package/src/results/writer.ts +258 -0
- package/src/runner/attempt.ts +3 -3
- package/src/runner/fingerprint.ts +1 -1
- package/src/runner/reporters/artifacts.ts +38 -78
- package/src/runner/reporters/braintrust.test.ts +2 -2
- package/src/runner/reporters/braintrust.ts +3 -3
- package/src/runner/run.ts +1 -1
- package/src/runner/types.ts +18 -12
- package/src/show/compose.ts +215 -0
- package/src/show/index.ts +263 -0
- package/src/show/render.ts +456 -0
- package/src/show/show.test.ts +505 -0
- package/src/view/app/App.tsx +85 -24
- package/src/view/app/components/CostScoreChart.tsx +29 -10
- package/src/view/app/components/ExperimentTable.tsx +27 -9
- package/src/view/app/components/GroupSelector.tsx +1 -1
- package/src/view/app/i18n.ts +18 -3
- package/src/view/app/lib/attempt-route.test.ts +9 -9
- package/src/view/app/lib/attempt-route.ts +5 -5
- package/src/view/app/lib/outcome.ts +1 -3
- package/src/view/app/lib/rows.ts +95 -15
- package/src/view/app/main.tsx +18 -6
- package/src/view/app/pages/RunsPage.tsx +4 -7
- package/src/view/app/pages/TracesPage.tsx +3 -8
- package/src/view/app/types.ts +50 -6
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +20 -20
- package/src/view/data.test.ts +254 -0
- package/src/view/data.ts +336 -0
- package/src/view/index.ts +64 -10
- package/src/view/server.ts +31 -9
- package/src/view/shared/types.ts +47 -41
- package/src/view/styles.css +5 -0
- package/src/view/template.html +1 -0
- package/src/view/view-report.test.ts +308 -0
- package/docs-site/.mintignore +0 -13
- package/docs-site/AGENTS.md +0 -46
- package/docs-site/concepts/adapter.mdx +0 -287
- package/docs-site/concepts/assert.mdx +0 -243
- package/docs-site/concepts/drive.mdx +0 -118
- package/docs-site/concepts/evals.mdx +0 -108
- package/docs-site/concepts/experiment.mdx +0 -37
- package/docs-site/concepts/hitl.mdx +0 -83
- package/docs-site/concepts/judge.mdx +0 -129
- package/docs-site/concepts/overview.mdx +0 -98
- package/docs-site/concepts/tier.mdx +0 -42
- package/docs-site/docs-ref/00-index.md +0 -23
- package/docs-site/docs-ref/01-page-types.md +0 -43
- package/docs-site/docs-ref/03-style-rules.md +0 -45
- package/docs-site/docs-ref/04-code-examples.md +0 -37
- package/docs-site/docs-ref/05-dx-failure-paths.md +0 -45
- package/docs-site/docs-ref/06-checklists.md +0 -53
- package/docs-site/docs.json +0 -256
- package/docs-site/example/ai-agent-application.mdx +0 -148
- package/docs-site/example/claude-code-codex-plugin.mdx +0 -162
- package/docs-site/example/claude-code-codex-skill.mdx +0 -147
- package/docs-site/example/showcase.mdx +0 -39
- package/docs-site/example/tier1-ai-sdk-v7.mdx +0 -136
- package/docs-site/example/tier1-claude-sdk.mdx +0 -119
- package/docs-site/example/tier1-codex-sdk.mdx +0 -111
- package/docs-site/example/tier1-langgraph.mdx +0 -119
- package/docs-site/example/tier1-pi-sdk.mdx +0 -119
- package/docs-site/favicon.svg +0 -5
- package/docs-site/github-diff.css +0 -135
- package/docs-site/github-diff.js +0 -11
- package/docs-site/guides/authoring.mdx +0 -129
- package/docs-site/guides/ci-integration.mdx +0 -97
- package/docs-site/guides/connect-otel.mdx +0 -209
- package/docs-site/guides/connect-your-agent.mdx +0 -221
- package/docs-site/guides/dataset-fanout.mdx +0 -98
- package/docs-site/guides/experiments.mdx +0 -71
- package/docs-site/guides/fixtures.mdx +0 -147
- package/docs-site/guides/reporters.mdx +0 -113
- package/docs-site/guides/runner.mdx +0 -79
- package/docs-site/guides/sandbox-agent.mdx +0 -138
- package/docs-site/guides/sandbox-backends.mdx +0 -64
- package/docs-site/guides/scoring-guide.mdx +0 -92
- package/docs-site/guides/viewing-results.mdx +0 -195
- package/docs-site/guides/write-experiment.mdx +0 -81
- package/docs-site/guides/write-send.mdx +0 -347
- package/docs-site/index.mdx +0 -181
- package/docs-site/introduction.mdx +0 -141
- package/docs-site/quickstart.mdx +0 -136
- package/docs-site/reference/builtin-agents.mdx +0 -161
- package/docs-site/reference/capabilities.mdx +0 -76
- package/docs-site/reference/cli.mdx +0 -120
- package/docs-site/reference/define-agent.mdx +0 -168
- package/docs-site/reference/define-config.mdx +0 -41
- package/docs-site/reference/define-eval.mdx +0 -160
- package/docs-site/reference/events.mdx +0 -131
- package/docs-site/reference/expect.mdx +0 -112
- package/docs-site/tracker.js +0 -8
- package/src/report/react/data.ts +0 -17
- package/src/view/aggregate.ts +0 -103
- package/src/view/loader.test.ts +0 -61
- package/src/view/loader.ts +0 -257
package/src/view/app/App.tsx
CHANGED
|
@@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
2
2
|
import { detectLocale, makeTranslator, persistLocale, setDocumentLocale } from "./i18n.ts";
|
|
3
3
|
import type { MessageKey } from "./i18n.ts";
|
|
4
4
|
import type { Locale, LocalizedText, SortKey, SortState, Tab, ViewData, ViewResult, ViewRow } from "./types.ts";
|
|
5
|
-
import { buildGroupMap, compareRows, resultFromUrl } from "./lib/rows.ts";
|
|
5
|
+
import { CELL_KEYS, buildGroupMap, buildRows, compareRows, flattenAttempts, resultFromUrl } from "./lib/rows.ts";
|
|
6
6
|
import { formatAttemptHash, parseAttemptHash, resolveAttemptRef, unresolvedAttemptWarning } from "./lib/attempt-route.ts";
|
|
7
|
-
import { formatCost, formatDateTime, formatDuration
|
|
7
|
+
import { formatCost, formatDateTime, formatDuration } from "./lib/format.ts";
|
|
8
8
|
import { Metric } from "./components/primitives.tsx";
|
|
9
9
|
import { GroupSelector } from "./components/GroupSelector.tsx";
|
|
10
10
|
import { CostScoreChart } from "./components/CostScoreChart.tsx";
|
|
@@ -15,12 +15,20 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs.t
|
|
|
15
15
|
import { RunsView } from "./pages/RunsPage.tsx";
|
|
16
16
|
import { TracesView } from "./pages/TracesPage.tsx";
|
|
17
17
|
|
|
18
|
+
// 报告槽只有一个:默认装官方榜单(Experiments tab);--report 整槽替换成用户报告。
|
|
19
|
+
// 证据室(Runs / Traces / AttemptModal)是 view 本体,两种模式下原样保留。
|
|
18
20
|
export const navItems: { id: Tab; label: MessageKey }[] = [
|
|
19
21
|
{ id: "experiments", label: "nav.experiments" },
|
|
20
22
|
{ id: "runs", label: "nav.runs" },
|
|
21
23
|
{ id: "traces", label: "nav.traces" },
|
|
22
24
|
];
|
|
23
25
|
|
|
26
|
+
export const reportNavItems: { id: Tab; label: MessageKey }[] = [
|
|
27
|
+
{ id: "report", label: "nav.report" },
|
|
28
|
+
{ id: "runs", label: "nav.runs" },
|
|
29
|
+
{ id: "traces", label: "nav.traces" },
|
|
30
|
+
];
|
|
31
|
+
|
|
24
32
|
/** 把 config.name 解析成当前界面语言的一条文案:字符串原样返回,多语言映射按 locale 挑,挑不到回退 en / 第一条。 */
|
|
25
33
|
export function localizedText(text: LocalizedText | undefined, locale: Locale): string | undefined {
|
|
26
34
|
if (!text) return undefined;
|
|
@@ -29,23 +37,26 @@ export function localizedText(text: LocalizedText | undefined, locale: Locale):
|
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
/** 初始 URL → 直接打开的 attempt:先认 #/attempt/<run>/<result> 深链,回退旧版 ?modal= 参数。 */
|
|
32
|
-
function modalResultFromLocation(
|
|
40
|
+
function modalResultFromLocation(snapshots: ViewData["snapshots"]): ViewResult | null {
|
|
33
41
|
const ref = parseAttemptHash(location.hash);
|
|
34
42
|
if (ref) {
|
|
35
|
-
const found = resolveAttemptRef(
|
|
43
|
+
const found = resolveAttemptRef(snapshots, ref);
|
|
36
44
|
if (found) return found;
|
|
37
45
|
// 定位不到(run 不在、下标越界、旧格式数据):不开空 modal,页面照常渲染。
|
|
38
46
|
console.warn(unresolvedAttemptWarning(location.hash));
|
|
39
47
|
return null;
|
|
40
48
|
}
|
|
41
|
-
return resultFromUrl(
|
|
49
|
+
return resultFromUrl(snapshots);
|
|
42
50
|
}
|
|
43
51
|
|
|
44
|
-
export function App({ data }: { data: ViewData }) {
|
|
45
|
-
const
|
|
52
|
+
export function App({ data, reportHtml }: { data: ViewData; reportHtml?: string }) {
|
|
53
|
+
const snapshots = data.snapshots ?? [];
|
|
54
|
+
const hasReport = reportHtml !== undefined;
|
|
55
|
+
const rows = useMemo(() => buildRows(data), [data]);
|
|
56
|
+
const attempts = useMemo(() => flattenAttempts(snapshots), [snapshots]);
|
|
46
57
|
const [locale, setLocale] = useState<Locale>(() => detectLocale());
|
|
47
58
|
const t = useMemo(() => makeTranslator(locale), [locale]);
|
|
48
|
-
const [tab, setTab] = useState<Tab>("experiments");
|
|
59
|
+
const [tab, setTab] = useState<Tab>(hasReport ? "report" : "experiments");
|
|
49
60
|
const [sort, setSort] = useState<SortState>({ key: "passRate", dir: -1 });
|
|
50
61
|
const [query, setQuery] = useState("");
|
|
51
62
|
const [openRows, setOpenRows] = useState<Set<string>>(() => new Set());
|
|
@@ -53,7 +64,7 @@ export function App({ data }: { data: ViewData }) {
|
|
|
53
64
|
const groups = [...new Set(rows.map((r) => r.group).filter(Boolean))].sort();
|
|
54
65
|
return groups[0] ?? null;
|
|
55
66
|
});
|
|
56
|
-
const [modalResult, setModalResult] = useState<ViewResult | null>(() => modalResultFromLocation(
|
|
67
|
+
const [modalResult, setModalResult] = useState<ViewResult | null>(() => modalResultFromLocation(snapshots));
|
|
57
68
|
// 当前 modal 的 hash 历史条目前面是否还有本页条目(本页 push 的 / 前进键回到的):
|
|
58
69
|
// true → UI 关闭走 history.back(),前进键还能重新打开;false(深链直接落地)→ 原地抹 hash,
|
|
59
70
|
// 免得 back 把用户弹出站外。
|
|
@@ -107,7 +118,7 @@ export function App({ data }: { data: ViewData }) {
|
|
|
107
118
|
setModalResult(null);
|
|
108
119
|
return;
|
|
109
120
|
}
|
|
110
|
-
const found = resolveAttemptRef(
|
|
121
|
+
const found = resolveAttemptRef(snapshots, ref);
|
|
111
122
|
if (!found) {
|
|
112
123
|
console.warn(unresolvedAttemptWarning(location.hash));
|
|
113
124
|
setModalResult(null);
|
|
@@ -119,7 +130,7 @@ export function App({ data }: { data: ViewData }) {
|
|
|
119
130
|
};
|
|
120
131
|
window.addEventListener("hashchange", onHashChange);
|
|
121
132
|
return () => window.removeEventListener("hashchange", onHashChange);
|
|
122
|
-
}, [
|
|
133
|
+
}, [snapshots]);
|
|
123
134
|
|
|
124
135
|
const groupMap = useMemo<Map<string, ViewRow[]>>(() => buildGroupMap(rows), [rows]);
|
|
125
136
|
const pool = selectedGroup ? groupMap.get(selectedGroup) ?? [] : rows;
|
|
@@ -158,6 +169,11 @@ export function App({ data }: { data: ViewData }) {
|
|
|
158
169
|
});
|
|
159
170
|
};
|
|
160
171
|
|
|
172
|
+
// hero 的官方数字:整体通过率是 MetricTable.data 常量维度的单行格子,其余来自 RunOverview.data。
|
|
173
|
+
const overallPassRate = data.overall?.rows[0]?.cells[CELL_KEYS.passRate];
|
|
174
|
+
const totals = data.overview?.totals;
|
|
175
|
+
const warnings = data.overview?.warnings ?? [];
|
|
176
|
+
|
|
161
177
|
return (
|
|
162
178
|
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
|
|
163
179
|
<header className="topbar">
|
|
@@ -166,7 +182,7 @@ export function App({ data }: { data: ViewData }) {
|
|
|
166
182
|
<span>NiceEval</span>
|
|
167
183
|
</a>
|
|
168
184
|
<TabsList aria-label={t("nav.label")}>
|
|
169
|
-
{navItems.map((item) => (
|
|
185
|
+
{(hasReport ? reportNavItems : navItems).map((item) => (
|
|
170
186
|
<TabsTrigger key={item.id} value={item.id}>
|
|
171
187
|
{t(item.label)}
|
|
172
188
|
</TabsTrigger>
|
|
@@ -194,27 +210,62 @@ export function App({ data }: { data: ViewData }) {
|
|
|
194
210
|
{/* viewData 只带原始值(ISO / number),这里按当前界面 locale 格式化。 */}
|
|
195
211
|
<b>{t("hero.lastRun")}</b> {data.lastRunAt ? formatDateTime(data.lastRunAt, locale) : t("hero.noRuns")}
|
|
196
212
|
</span>
|
|
213
|
+
{data.composedRuns > 0 ? (
|
|
214
|
+
// 榜单是跨 run 合成的现刻水位,表头如实标注合成来源(几个 run)。
|
|
215
|
+
<span>{t("hero.composedFrom", { count: data.composedRuns })}</span>
|
|
216
|
+
) : null}
|
|
197
217
|
</div>
|
|
198
218
|
</section>
|
|
199
219
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
|
|
220
|
+
{/* 官方水位 KPI 与挑选警告属于报告槽:--report 整槽替换后由用户报告自己决定
|
|
221
|
+
摆不摆(<DefaultReport /> / <RunOverview /> 都能把它们摆回来)。 */}
|
|
222
|
+
{!hasReport && (
|
|
223
|
+
<section className="summary" aria-label="Run summary">
|
|
224
|
+
<Metric label={t("metric.passRate")} value={overallPassRate?.display ?? "—"} />
|
|
225
|
+
<Metric label={t("metric.evalResults")} value={String(totals?.evals ?? 0)} />
|
|
226
|
+
<Metric label={t("metric.duration")} value={formatDuration(totals?.durationMs ?? 0)} />
|
|
227
|
+
<Metric label={t("metric.cost")} value={formatCost(totals?.costUSD ?? undefined)} />
|
|
228
|
+
</section>
|
|
229
|
+
)}
|
|
230
|
+
|
|
231
|
+
{!hasReport && warnings.length > 0 && (
|
|
232
|
+
// 选集警告(partial-coverage / stale-snapshot / synthetic-experiment-id):
|
|
233
|
+
// message 是挑选器渲染好的英文句子,原样打;data-kind 供样式与测试定位。
|
|
234
|
+
<section className="incompatible-banner selection-warnings" role="alert">
|
|
235
|
+
<b>{t("banner.warningsTitle")}</b>
|
|
236
|
+
<ul>
|
|
237
|
+
{warnings.map((w, i) => (
|
|
238
|
+
<li key={`${w.kind}-${i}`} data-kind={w.kind}>
|
|
239
|
+
<span className="ib-meta">{w.message}</span>
|
|
240
|
+
</li>
|
|
241
|
+
))}
|
|
242
|
+
</ul>
|
|
243
|
+
</section>
|
|
244
|
+
)}
|
|
206
245
|
|
|
207
246
|
{(data.skippedRuns?.length ?? 0) > 0 && (
|
|
208
247
|
<section className="incompatible-banner" role="alert">
|
|
209
248
|
<b>{t("banner.skippedTitle")}</b>
|
|
210
249
|
<ul>
|
|
211
250
|
{data.skippedRuns!.map((run) => (
|
|
212
|
-
<li key={run.dir}>
|
|
251
|
+
<li key={run.dir} data-reason={run.reason}>
|
|
213
252
|
<span className="ib-dir">{run.dir}</span>
|
|
214
253
|
<span className="ib-meta">
|
|
215
|
-
{run.reason === "
|
|
216
|
-
? t("banner.skipped.
|
|
217
|
-
:
|
|
254
|
+
{run.reason === "malformed"
|
|
255
|
+
? t("banner.skipped.malformed", { detail: run.detail ?? "?" })
|
|
256
|
+
: run.reason === "incomplete"
|
|
257
|
+
? t("banner.skipped.incomplete")
|
|
258
|
+
: run.producerName && run.producerName !== "niceeval"
|
|
259
|
+
? // 第三方 harness 写的落盘:如实报名字和版本,不拼 npx 命令。
|
|
260
|
+
t("banner.skipped.incompatibleForeign", {
|
|
261
|
+
name: run.producerName,
|
|
262
|
+
version: run.producerVersion ?? "?",
|
|
263
|
+
schemaVersion: run.schemaVersion,
|
|
264
|
+
})
|
|
265
|
+
: t("banner.skipped.incompatible", {
|
|
266
|
+
producer: run.producerVersion ?? "?",
|
|
267
|
+
schemaVersion: run.schemaVersion,
|
|
268
|
+
})}
|
|
218
269
|
</span>
|
|
219
270
|
{run.command ? <code>{run.command}</code> : null}
|
|
220
271
|
</li>
|
|
@@ -223,6 +274,15 @@ export function App({ data }: { data: ViewData }) {
|
|
|
223
274
|
</section>
|
|
224
275
|
)}
|
|
225
276
|
|
|
277
|
+
{hasReport && (
|
|
278
|
+
<TabsContent value="report" id="tab-report">
|
|
279
|
+
{/* 报告槽:server 侧渲染好的静态 HTML(含 <Style> 产物),这里只摆放。
|
|
280
|
+
attempt 深链是普通 <a href="#/attempt/…">,经 hashchange 打开证据室弹窗。 */}
|
|
281
|
+
<div className="report-slot" dangerouslySetInnerHTML={{ __html: reportHtml }} />
|
|
282
|
+
</TabsContent>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{!hasReport && (
|
|
226
286
|
<TabsContent value="experiments" id="tab-experiments">
|
|
227
287
|
<div className="section-head">
|
|
228
288
|
<h2>{t("section.experiments")}</h2>
|
|
@@ -261,12 +321,13 @@ export function App({ data }: { data: ViewData }) {
|
|
|
261
321
|
</div>
|
|
262
322
|
)}
|
|
263
323
|
</TabsContent>
|
|
324
|
+
)}
|
|
264
325
|
|
|
265
326
|
<TabsContent value="runs">
|
|
266
|
-
<RunsView
|
|
327
|
+
<RunsView attempts={attempts} t={t} />
|
|
267
328
|
</TabsContent>
|
|
268
329
|
<TabsContent value="traces">
|
|
269
|
-
<TracesView
|
|
330
|
+
<TracesView attempts={attempts} t={t} />
|
|
270
331
|
</TabsContent>
|
|
271
332
|
</main>
|
|
272
333
|
{modalResult && <AttemptModal result={modalResult} onClose={closeModal} t={t} />}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import type { T } from "../shared.ts";
|
|
3
3
|
import type { ViewRow } from "../types.ts";
|
|
4
|
+
import { CELL_KEYS } from "../lib/rows.ts";
|
|
4
5
|
import { formatCost, formatPercent } from "../lib/format.ts";
|
|
5
6
|
import { layoutLabelOffsets, niceTicks, seriesColor } from "../lib/chart.ts";
|
|
6
7
|
|
|
@@ -13,22 +14,40 @@ function formatAxisCost(value: number): string {
|
|
|
13
14
|
return value === 0 ? "$0" : formatCost(value);
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
interface ChartPoint {
|
|
18
|
+
key: string;
|
|
19
|
+
label: string;
|
|
20
|
+
agent: string;
|
|
21
|
+
model?: string;
|
|
22
|
+
/** 官方 cost 格子的值(平均每 eval 成本,与轴标签同口径)。 */
|
|
23
|
+
cost: number;
|
|
24
|
+
/** 官方 pass-rate 格子的值(0–1)。 */
|
|
25
|
+
rate: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* 一个 group 内「成本 vs 通过率」散点图:一行(一个实验/config)= 一个点。
|
|
18
|
-
*
|
|
30
|
+
* 点位取官方 MetricTable.data 的格子值;任一轴缺数据(null)的行不画。
|
|
31
|
+
* 只在 group 内至少两条 row 有成本时渲染,否则没有比较意义。
|
|
19
32
|
*/
|
|
20
33
|
export function CostScoreChart({ rows, t }: { rows: ViewRow[]; t: T }) {
|
|
21
34
|
const [hoverKey, setHoverKey] = useState<string | null>(null);
|
|
22
|
-
const points
|
|
35
|
+
const points: ChartPoint[] = [];
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
const cost = row.cells[CELL_KEYS.cost]?.value ?? null;
|
|
38
|
+
const rate = row.cells[CELL_KEYS.passRate]?.value ?? null;
|
|
39
|
+
if (cost === null || rate === null || !Number.isFinite(cost)) continue;
|
|
40
|
+
points.push({ key: row.key, label: row.label, agent: row.agent, model: row.model, cost, rate });
|
|
41
|
+
}
|
|
23
42
|
if (points.length < 2) return null;
|
|
24
43
|
|
|
25
44
|
const plotW = WIDTH - MARGIN.left - MARGIN.right;
|
|
26
45
|
const plotH = HEIGHT - MARGIN.top - MARGIN.bottom;
|
|
27
46
|
|
|
28
|
-
const xTicks = niceTicks(0, Math.max(...points.map((p) => p.
|
|
47
|
+
const xTicks = niceTicks(0, Math.max(...points.map((p) => p.cost)), 5);
|
|
29
48
|
const xMax = xTicks[xTicks.length - 1] || 1;
|
|
30
49
|
|
|
31
|
-
const rates = points.map((p) => p.
|
|
50
|
+
const rates = points.map((p) => p.rate);
|
|
32
51
|
const rateMin = Math.min(...rates);
|
|
33
52
|
const rateMax = Math.max(...rates);
|
|
34
53
|
const pad = Math.max(0.03, (rateMax - rateMin) * 0.25);
|
|
@@ -44,8 +63,8 @@ export function CostScoreChart({ rows, t }: { rows: ViewRow[]; t: T }) {
|
|
|
44
63
|
const hovered = points.find((p) => p.key === hoverKey);
|
|
45
64
|
|
|
46
65
|
const positions = points.map((p) => {
|
|
47
|
-
const cx = xScale(p.
|
|
48
|
-
const cy = yScale(p.
|
|
66
|
+
const cx = xScale(p.cost);
|
|
67
|
+
const cy = yScale(p.rate);
|
|
49
68
|
const anchorLeft = cx >= MARGIN.left + plotW * 0.72;
|
|
50
69
|
return { cx, cy, anchorLeft, width: p.label.length * 6.4 + 10 };
|
|
51
70
|
});
|
|
@@ -93,7 +112,7 @@ export function CostScoreChart({ rows, t }: { rows: ViewRow[]; t: T }) {
|
|
|
93
112
|
className="csc-point"
|
|
94
113
|
tabIndex={0}
|
|
95
114
|
role="button"
|
|
96
|
-
aria-label={`${p.label}: ${formatCost(p.
|
|
115
|
+
aria-label={`${p.label}: ${formatCost(p.cost)}, ${formatPercent(p.rate)}`}
|
|
97
116
|
onMouseEnter={() => setHoverKey(p.key)}
|
|
98
117
|
onMouseLeave={() => setHoverKey((k) => (k === p.key ? null : k))}
|
|
99
118
|
onFocus={() => setHoverKey(p.key)}
|
|
@@ -114,11 +133,11 @@ export function CostScoreChart({ rows, t }: { rows: ViewRow[]; t: T }) {
|
|
|
114
133
|
{hovered ? (
|
|
115
134
|
<div
|
|
116
135
|
className="csc-tooltip"
|
|
117
|
-
style={{ left: `${(xScale(hovered.
|
|
136
|
+
style={{ left: `${(xScale(hovered.cost) / WIDTH) * 100}%`, top: `${(yScale(hovered.rate) / HEIGHT) * 100}%` }}
|
|
118
137
|
>
|
|
119
|
-
<b>{formatPercent(hovered.
|
|
138
|
+
<b>{formatPercent(hovered.rate)}</b> {t("chart.axisScore")}
|
|
120
139
|
<div className="csc-tooltip-meta">
|
|
121
|
-
{formatCost(hovered.
|
|
140
|
+
{formatCost(hovered.cost)} · {hovered.model || hovered.agent}
|
|
122
141
|
</div>
|
|
123
142
|
</div>
|
|
124
143
|
) : null}
|
|
@@ -3,9 +3,17 @@ import { ChevronRight } from "lucide-react";
|
|
|
3
3
|
import type { OpenModal, T } from "../shared.ts";
|
|
4
4
|
import type { Assertion, SortKey, SortState, ViewResult, ViewRow } from "../types.ts";
|
|
5
5
|
import { EvalGroup, failingAssertions, groupByEval, outcomeClass, outcomeLabel, outcomeSummary, reasonFor, scoresSummary } from "../lib/outcome.ts";
|
|
6
|
-
import { configChips } from "../lib/rows.ts";
|
|
7
|
-
import { formatClock, formatCost, formatDateTime, formatDuration,
|
|
6
|
+
import { CELL_KEYS, configChips } from "../lib/rows.ts";
|
|
7
|
+
import { formatClock, formatCost, formatDateTime, formatDuration, formatTokens, totalTokens } from "../lib/format.ts";
|
|
8
8
|
import { Kpi, SortHeader } from "./primitives.tsx";
|
|
9
|
+
import type { MetricCell } from "../types.ts";
|
|
10
|
+
|
|
11
|
+
/** 官方格子的渲染:display 已格式化;samples < total 时 title 如实报覆盖率(有 attempt 测不了这个指标)。 */
|
|
12
|
+
function CellValue({ cell }: { cell: MetricCell | undefined }) {
|
|
13
|
+
if (!cell || cell.value === null) return <>—</>;
|
|
14
|
+
const partial = cell.samples < cell.total;
|
|
15
|
+
return <span title={partial ? `${cell.samples}/${cell.total} attempts measured` : undefined}>{cell.display}</span>;
|
|
16
|
+
}
|
|
9
17
|
|
|
10
18
|
export function ExperimentTable({
|
|
11
19
|
rows,
|
|
@@ -32,7 +40,7 @@ export function ExperimentTable({
|
|
|
32
40
|
<SortHeader name={t("table.experiment")} sortKey="experiment" sort={sort} onSort={setSortKey} />
|
|
33
41
|
<SortHeader name={t("table.model")} sortKey="model" sort={sort} onSort={setSortKey} />
|
|
34
42
|
<SortHeader name={t("table.agent")} sortKey="agent" sort={sort} onSort={setSortKey} />
|
|
35
|
-
<SortHeader name={t("table.avgDuration")} sortKey="
|
|
43
|
+
<SortHeader name={t("table.avgDuration")} sortKey="duration" sort={sort} onSort={setSortKey} />
|
|
36
44
|
<SortHeader name={t("table.successRate")} sortKey="passRate" sort={sort} onSort={setSortKey} />
|
|
37
45
|
<SortHeader name={t("table.tokens")} sortKey="tokens" sort={sort} onSort={setSortKey} />
|
|
38
46
|
<SortHeader name={t("table.estCost")} sortKey="cost" sort={sort} onSort={setSortKey} />
|
|
@@ -53,7 +61,8 @@ export function ExperimentTable({
|
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
export function ExperimentRow({ row, open, onToggle, t }: { row: ViewRow; open: boolean; onToggle: () => void; t: T }) {
|
|
56
|
-
const
|
|
64
|
+
const passRate = row.cells[CELL_KEYS.passRate]?.value ?? null;
|
|
65
|
+
const tone = passRate === null ? "" : passRate >= 0.8 ? "good" : passRate >= 0.5 ? "warn" : "bad";
|
|
57
66
|
return (
|
|
58
67
|
<tr
|
|
59
68
|
className={`main-row${open ? " is-open" : ""}`}
|
|
@@ -77,10 +86,19 @@ export function ExperimentRow({ row, open, onToggle, t }: { row: ViewRow; open:
|
|
|
77
86
|
</td>
|
|
78
87
|
<td>{row.model || t("config.default")}</td>
|
|
79
88
|
<td>{row.agent}</td>
|
|
80
|
-
|
|
81
|
-
<td className=
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
{/* 官方 MetricTable.data 的格子:display 直接渲染,数字口径与 show 榜单同源。 */}
|
|
90
|
+
<td className="num">
|
|
91
|
+
<CellValue cell={row.cells[CELL_KEYS.duration]} />
|
|
92
|
+
</td>
|
|
93
|
+
<td className={`num ${tone}`}>
|
|
94
|
+
<CellValue cell={row.cells[CELL_KEYS.passRate]} />
|
|
95
|
+
</td>
|
|
96
|
+
<td className="num">
|
|
97
|
+
<CellValue cell={row.cells[CELL_KEYS.tokens]} />
|
|
98
|
+
</td>
|
|
99
|
+
<td className="num">
|
|
100
|
+
<CellValue cell={row.cells[CELL_KEYS.cost]} />
|
|
101
|
+
</td>
|
|
84
102
|
<td>
|
|
85
103
|
<span className="pill">{outcomeSummary(row, t)}</span>
|
|
86
104
|
</td>
|
|
@@ -115,7 +133,7 @@ export function ExperimentDetail({ row, openModal, t }: { row: ViewRow; openModa
|
|
|
115
133
|
<Kpi label={t("detail.errored")} value={row.errored} className={row.errored ? "infra-err" : ""} />
|
|
116
134
|
{row.runs > row.evals ? <Kpi label={t("detail.runs")} value={row.runs} /> : null}
|
|
117
135
|
<Kpi label={t("detail.totalTime")} value={formatDuration(totalDuration)} />
|
|
118
|
-
<Kpi label={t("detail.totalCost")} value={formatCost(row.
|
|
136
|
+
<Kpi label={t("detail.totalCost")} value={formatCost(row.totalCostUSD)} />
|
|
119
137
|
<Kpi label={t("detail.ran")} value={formatDateTime(row.lastRunAt)} title={row.lastRunAt || ""} />
|
|
120
138
|
</div>
|
|
121
139
|
<h3>{t("detail.evaluationAttempts")}</h3>
|
|
@@ -25,7 +25,7 @@ export function GroupSelector({
|
|
|
25
25
|
const errored = evalGroups.filter((g) => g.outcome === "errored").length;
|
|
26
26
|
const passRate = evalPassRate(allResults);
|
|
27
27
|
const tone = passRate >= 0.8 ? "good" : passRate >= 0.5 ? "warn" : "bad";
|
|
28
|
-
const totalCost = groupRows.reduce((s: number, r: ViewRow) => s + (r.
|
|
28
|
+
const totalCost = groupRows.reduce((s: number, r: ViewRow) => s + (r.totalCostUSD || 0), 0);
|
|
29
29
|
const lastRun = groupRows
|
|
30
30
|
.map((r: ViewRow) => r.lastRunAt)
|
|
31
31
|
.filter((value): value is string => Boolean(value))
|
package/src/view/app/i18n.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { interpolate, normalizeLocale, type Locale, type Vars } from "../../i18n
|
|
|
6
6
|
export type MessageKey =
|
|
7
7
|
| "app.title"
|
|
8
8
|
| "nav.label"
|
|
9
|
+
| "nav.report"
|
|
9
10
|
| "nav.experiments"
|
|
10
11
|
| "nav.runs"
|
|
11
12
|
| "nav.traces"
|
|
@@ -57,7 +58,7 @@ export type MessageKey =
|
|
|
57
58
|
| "detail.rawSample"
|
|
58
59
|
| "detail.rawNote"
|
|
59
60
|
| "config.experiment"
|
|
60
|
-
| "config.
|
|
61
|
+
| "config.paramsNone"
|
|
61
62
|
| "config.default"
|
|
62
63
|
| "config.none"
|
|
63
64
|
| "config.notApplicable"
|
|
@@ -109,7 +110,11 @@ export type MessageKey =
|
|
|
109
110
|
| "outcome.skipped"
|
|
110
111
|
| "banner.skippedTitle"
|
|
111
112
|
| "banner.skipped.incompatible"
|
|
113
|
+
| "banner.skipped.incompatibleForeign"
|
|
112
114
|
| "banner.skipped.malformed"
|
|
115
|
+
| "banner.skipped.incomplete"
|
|
116
|
+
| "banner.warningsTitle"
|
|
117
|
+
| "hero.composedFrom"
|
|
113
118
|
| "chart.costVsScore"
|
|
114
119
|
| "chart.axisCost"
|
|
115
120
|
| "chart.axisScore";
|
|
@@ -120,6 +125,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
|
|
120
125
|
en: {
|
|
121
126
|
"app.title": "niceeval experiment view",
|
|
122
127
|
"nav.label": "Report",
|
|
128
|
+
"nav.report": "Report",
|
|
123
129
|
"nav.experiments": "Experiments",
|
|
124
130
|
"nav.runs": "Runs",
|
|
125
131
|
"nav.traces": "Traces",
|
|
@@ -171,7 +177,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
|
|
171
177
|
"detail.rawSample": "Raw sample result",
|
|
172
178
|
"detail.rawNote": "debug JSON, defaults to first error/failure when available",
|
|
173
179
|
"config.experiment": "experiment",
|
|
174
|
-
"config.
|
|
180
|
+
"config.paramsNone": "none",
|
|
175
181
|
"config.default": "default",
|
|
176
182
|
"config.none": "none",
|
|
177
183
|
"config.notApplicable": "n/a",
|
|
@@ -223,7 +229,11 @@ const dictionaries: Record<Locale, Dictionary> = {
|
|
|
223
229
|
"outcome.skipped": "skipped",
|
|
224
230
|
"banner.skippedTitle": "Some runs could not be loaded and are not shown here:",
|
|
225
231
|
"banner.skipped.incompatible": "written by niceeval {{producer}} (schemaVersion {{schemaVersion}}) — view it with the command on the right",
|
|
232
|
+
"banner.skipped.incompatibleForeign": "written by {{name}} {{version}} (schemaVersion {{schemaVersion}}) — this viewer cannot read it; open it with the tool that produced it",
|
|
226
233
|
"banner.skipped.malformed": "unreadable report ({{detail}}) — it may be corrupted; re-run the eval or delete this run directory",
|
|
234
|
+
"banner.skipped.incomplete": "the run crashed before writing summary.json — completed attempt artifacts remain on disk for manual inspection; delete the directory if you no longer need them",
|
|
235
|
+
"banner.warningsTitle": "Heads-up about the current leaderboard selection:",
|
|
236
|
+
"hero.composedFrom": "Composed from {{count}} run(s)",
|
|
227
237
|
"chart.costVsScore": "Cost vs. Score",
|
|
228
238
|
"chart.axisCost": "Avg cost per eval",
|
|
229
239
|
"chart.axisScore": "Pass rate",
|
|
@@ -231,6 +241,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
|
|
231
241
|
"zh-CN": {
|
|
232
242
|
"app.title": "niceeval 实验查看器",
|
|
233
243
|
"nav.label": "报告",
|
|
244
|
+
"nav.report": "报告",
|
|
234
245
|
"nav.experiments": "实验",
|
|
235
246
|
"nav.runs": "运行",
|
|
236
247
|
"nav.traces": "追踪",
|
|
@@ -282,7 +293,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
|
|
282
293
|
"detail.rawSample": "原始样例结果",
|
|
283
294
|
"detail.rawNote": "调试 JSON,默认选择第一条错误/失败",
|
|
284
295
|
"config.experiment": "实验",
|
|
285
|
-
"config.
|
|
296
|
+
"config.paramsNone": "无",
|
|
286
297
|
"config.default": "默认",
|
|
287
298
|
"config.none": "无",
|
|
288
299
|
"config.notApplicable": "不适用",
|
|
@@ -334,7 +345,11 @@ const dictionaries: Record<Locale, Dictionary> = {
|
|
|
334
345
|
"outcome.skipped": "跳过",
|
|
335
346
|
"banner.skippedTitle": "以下 run 读取失败,此处不展示:",
|
|
336
347
|
"banner.skipped.incompatible": "由 niceeval {{producer}} 写入(schemaVersion {{schemaVersion}})—— 用右侧命令查看",
|
|
348
|
+
"banner.skipped.incompatibleForeign": "由 {{name}} {{version}} 写入(schemaVersion {{schemaVersion}})—— 当前查看器读不了;请用写出它的工具查看",
|
|
337
349
|
"banner.skipped.malformed": "报告读不了({{detail}})—— 可能已损坏;重跑该 eval 或删除这个 run 目录",
|
|
350
|
+
"banner.skipped.incomplete": "该 run 中途崩溃、没写出 summary.json —— 已完成的 attempt 工件仍在盘上供手工排查;不需要就删除该目录",
|
|
351
|
+
"banner.warningsTitle": "当前榜单挑选的提醒:",
|
|
352
|
+
"hero.composedFrom": "合成自 {{count}} 个 run",
|
|
338
353
|
"chart.costVsScore": "成本 × 通过率",
|
|
339
354
|
"chart.axisCost": "平均每个 eval 成本",
|
|
340
355
|
"chart.axisScore": "通过率",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
4
|
import { formatAttemptHash, parseAttemptHash, resolveAttemptRef } from "./attempt-route.ts";
|
|
5
|
-
import type { ViewResult,
|
|
5
|
+
import type { ViewResult, ViewSnapshot } from "../types.ts";
|
|
6
6
|
|
|
7
7
|
const attempt = (run: string, index: number): ViewResult => ({
|
|
8
8
|
id: `demo/eval-${index}`,
|
|
@@ -14,8 +14,8 @@ const attempt = (run: string, index: number): ViewResult => ({
|
|
|
14
14
|
attemptRef: { run, result: index },
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
//
|
|
18
|
-
const
|
|
17
|
+
// 快照只有 results 参与定位,其余元信息字段与路由无关。
|
|
18
|
+
const snap = (results: ViewResult[]): ViewSnapshot => ({ results }) as ViewSnapshot;
|
|
19
19
|
|
|
20
20
|
describe("parseAttemptHash", () => {
|
|
21
21
|
it("parses the canonical run-dir + index shape", () => {
|
|
@@ -69,21 +69,21 @@ describe("parseAttemptHash", () => {
|
|
|
69
69
|
describe("resolveAttemptRef", () => {
|
|
70
70
|
const runA = "2026-07-01T10-00-00-000Z";
|
|
71
71
|
const runB = "2026-07-02T10-00-00-000Z";
|
|
72
|
-
const
|
|
72
|
+
const snapshots = [snap([attempt(runA, 0), attempt(runB, 0)]), snap([attempt(runA, 1)])];
|
|
73
73
|
|
|
74
74
|
it("finds the attempt whose injected ref matches run + index", () => {
|
|
75
|
-
expect(resolveAttemptRef(
|
|
76
|
-
expect(resolveAttemptRef(
|
|
75
|
+
expect(resolveAttemptRef(snapshots, { run: runA, result: 1 })).toBe(snapshots[1]!.results[0]);
|
|
76
|
+
expect(resolveAttemptRef(snapshots, { run: runB, result: 0 })).toBe(snapshots[0]!.results[1]);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
it("returns null for unknown runs and out-of-range indexes", () => {
|
|
80
|
-
expect(resolveAttemptRef(
|
|
81
|
-
expect(resolveAttemptRef(
|
|
80
|
+
expect(resolveAttemptRef(snapshots, { run: "no-such-run", result: 0 })).toBeNull();
|
|
81
|
+
expect(resolveAttemptRef(snapshots, { run: runA, result: 99 })).toBeNull();
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
it("returns null when results predate attemptRef injection (old baked data)", () => {
|
|
85
85
|
const legacy = attempt(runA, 0);
|
|
86
86
|
delete (legacy as { attemptRef?: unknown }).attemptRef;
|
|
87
|
-
expect(resolveAttemptRef([
|
|
87
|
+
expect(resolveAttemptRef([snap([legacy])], { run: runA, result: 0 })).toBeNull();
|
|
88
88
|
});
|
|
89
89
|
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// 这里只做纯解析 / 格式化 / 匹配,不碰 location / history,方便单测。
|
|
4
4
|
// hash 目前只有这一种路由:tab 切换是纯组件 state,旧版 modal 深链走 ?modal= 查询参数,互不占用。
|
|
5
5
|
|
|
6
|
-
import type { AttemptRef, ViewResult,
|
|
6
|
+
import type { AttemptRef, ViewResult, ViewSnapshot } from "../types.ts";
|
|
7
7
|
|
|
8
8
|
export const ATTEMPT_HASH_PREFIX = "#/attempt/";
|
|
9
9
|
|
|
@@ -33,10 +33,10 @@ export function parseAttemptHash(hash: string): AttemptRef | null {
|
|
|
33
33
|
return { run, result: parseInt(indexPart, 10) };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
export function resolveAttemptRef(
|
|
38
|
-
for (const
|
|
39
|
-
for (const result of
|
|
36
|
+
/** 在全部快照(含历史)里找 AttemptRef 指向的 attempt;旧格式烘焙的数据没有 attemptRef,自然找不到。 */
|
|
37
|
+
export function resolveAttemptRef(snapshots: ViewSnapshot[], ref: AttemptRef): ViewResult | null {
|
|
38
|
+
for (const snapshot of snapshots) {
|
|
39
|
+
for (const result of snapshot.results ?? []) {
|
|
40
40
|
if (result.attemptRef?.run === ref.run && result.attemptRef.result === ref.result) return result;
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -75,9 +75,7 @@ export function scoresSummary(assertions: Assertion[]): string {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
export function outcomeSummary(row: ViewRow, t: T): string {
|
|
78
|
-
|
|
79
|
-
const passed = (row.passed || 0) + (row.scored || 0);
|
|
80
|
-
const parts = [`${passed} ${t("outcome.passed")}`, `${row.failed} ${t("outcome.failed")}`];
|
|
78
|
+
const parts = [`${row.passed} ${t("outcome.passed")}`, `${row.failed} ${t("outcome.failed")}`];
|
|
81
79
|
if (row.errored) parts.push(`${row.errored} ${t("outcome.errored")}`);
|
|
82
80
|
if (row.skipped) parts.push(`${row.skipped} ${t("outcome.skipped")}`);
|
|
83
81
|
return parts.join(" / ");
|