niceeval 0.7.0 → 0.8.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/dist/report/index.d.ts +1 -1
- package/dist/report/report.d.ts +23 -1
- package/dist/report/report.js +82 -5
- package/docs-site/zh/how-to/custom-reports.mdx +3 -3
- package/docs-site/zh/how-to/publish-report.mdx +42 -5
- package/docs-site/zh/how-to/viewing-results.mdx +4 -4
- package/docs-site/zh/reference/cli.mdx +2 -2
- package/docs-site/zh/reference/results-data.mdx +1 -1
- package/docs-site/zh/troubleshooting/debugging.mdx +2 -2
- package/package.json +7 -6
- package/src/report/index.ts +1 -0
- package/src/report/report.ts +128 -6
- package/src/report/shell-head.test.ts +102 -0
- package/src/show/report-host.ts +13 -0
- package/src/view/app/components/CodeView.test.tsx +142 -0
- package/src/view/app/components/CodeView.tsx +15 -1
- package/src/view/app/components/Transcript.tsx +28 -1
- package/src/view/app/i18n.ts +6 -0
- package/src/view/app/lib/artifact-url.ts +14 -3
- package/src/view/app/lib/guards.test.ts +108 -0
- package/src/view/app/lib/guards.ts +13 -3
- package/src/view/app/lib/transcript-data.tsx +14 -0
- package/src/view/app/types.ts +17 -1
- package/src/view/artifact-serving.test.ts +21 -1
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +20 -20
- package/src/view/data.ts +54 -3
- package/src/view/index.ts +18 -47
- package/src/view/server.ts +49 -142
- package/src/view/site-head.test.ts +177 -0
- package/src/view/site-parity.test.ts +117 -0
- package/src/view/site.ts +209 -0
- package/src/view/styles.css +10 -0
- package/src/view/view-report.test.ts +1 -1
package/src/report/report.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// defineReport:唯一可被宿主装载的产物 —— 一层外壳(
|
|
1
|
+
// defineReport:唯一可被宿主装载的产物 —— 一层外壳(标题、外链、页脚、head 标签、脚本、样式)加
|
|
2
2
|
// 非空页列表;单页与多页不是两种机制,页数只是列表长度(docs/feature/reports/library/shell.md)。
|
|
3
3
|
// 入参有两级缩写,各有精确展开:树入参 ≡ { content: 树 } ≡ pages: [{ id: "report",
|
|
4
4
|
// title: 内置页名, content: 树 }]。`content` 与 `pages` 恰好声明一个,没有隐式默认。
|
|
@@ -40,6 +40,16 @@ export interface ReportLink {
|
|
|
40
40
|
/** src 是相对顶层报告文件的路径;两种形态不可同时出现。 */
|
|
41
41
|
export type ReportAsset = { src: string; inline?: never } | { inline: string; src?: never };
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* 结构化 head 标签。tag 是白名单闭集——head 是元数据与第三方脚本的注入口,不是 HTML 后门。
|
|
45
|
+
* attrs 值为 true 渲染裸布尔属性(async、defer),字符串渲染 `key="value"`(值转义后落 HTML);
|
|
46
|
+
* 属性语义与脚本内容同一约定——作者义务,宿主不校验。
|
|
47
|
+
* meta / link 无子内容由类型表达;script / style 的 children 是原样文本,不转义。
|
|
48
|
+
*/
|
|
49
|
+
export type HeadTag =
|
|
50
|
+
| { tag: "meta" | "link"; attrs: Record<string, string | true>; children?: never }
|
|
51
|
+
| { tag: "script" | "style"; attrs?: Record<string, string | true>; children?: string };
|
|
52
|
+
|
|
43
53
|
export interface ReportShell {
|
|
44
54
|
/** 标题:首页 hero 与浏览器标题。页头左端是恒定的 NiceEval 品牌字标,不由 title 覆盖;回退链 def.title → 唯一快照 name → 内置文案「Eval 运行结果 / Eval Results」。 */
|
|
45
55
|
title?: LocalizedText;
|
|
@@ -47,6 +57,12 @@ export interface ReportShell {
|
|
|
47
57
|
links?: ReportLink[];
|
|
48
58
|
/** 每页页脚的一段文字;省略时不渲染页脚(品牌行恒在 hero 下方,不占页脚)。 */
|
|
49
59
|
footer?: LocalizedText;
|
|
60
|
+
/**
|
|
61
|
+
* 注入每页 `<head>` 的结构化标签,在官方与外壳样式之后按声明顺序渲染。
|
|
62
|
+
* 第三方 snippet(分析、埋点、评论)、SEO meta、favicon、字体、JSON-LD 的家:
|
|
63
|
+
* 声明什么标签就渲染什么标签,宿主只做结构校验,新的第三方接入不需要契约变更。
|
|
64
|
+
*/
|
|
65
|
+
head?: HeadTag[];
|
|
50
66
|
/** 注入每个页面的脚本,在官方增强脚本之后、按声明顺序于 </body> 前加载。 */
|
|
51
67
|
scripts?: ReportAsset[];
|
|
52
68
|
/** 注入每个页面的样式表,在官方样式之后按声明顺序加载。 */
|
|
@@ -76,13 +92,14 @@ const REPORT_DEFINITION: unique symbol = Symbol.for("niceeval.report.definition"
|
|
|
76
92
|
/**
|
|
77
93
|
* defineReport 的唯一产物:只作 --report 文件的默认导出,交给宿主装载。
|
|
78
94
|
* 它不是 ReportNode——不能放进任何 content 或报告树,外壳因此不可嵌套。
|
|
79
|
-
* 字段是装载规范化后的形态:pages 恒非空,links / scripts / styles 恒为数组。
|
|
95
|
+
* 字段是装载规范化后的形态:pages 恒非空,links / head / scripts / styles 恒为数组。
|
|
80
96
|
*/
|
|
81
97
|
export interface ReportDefinition {
|
|
82
98
|
readonly kind: "report";
|
|
83
99
|
readonly title?: LocalizedText;
|
|
84
100
|
readonly links: readonly ReportLink[];
|
|
85
101
|
readonly footer?: LocalizedText;
|
|
102
|
+
readonly head: readonly HeadTag[];
|
|
86
103
|
readonly scripts: readonly ReportAsset[];
|
|
87
104
|
readonly styles: readonly ReportAsset[];
|
|
88
105
|
readonly pages: NonEmptyArray<ReportPage>;
|
|
@@ -161,6 +178,16 @@ function assertLocalizedText(value: unknown, where: string): asserts value is Lo
|
|
|
161
178
|
|
|
162
179
|
const PAGE_ID_PATTERN = /^[a-z0-9-]+$/;
|
|
163
180
|
|
|
181
|
+
/** 本地资产路径纪律(shell.md「行为约束」):相对报告文件的普通相对路径,拒绝 `..` 段、绝对路径与 `~`。 */
|
|
182
|
+
function assertLocalAssetPath(src: string, where: string): void {
|
|
183
|
+
const segments = src.split(/[\\/]+/);
|
|
184
|
+
if (src.startsWith("/") || /^[A-Za-z]:/.test(src) || src.startsWith("~") || segments.includes("..")) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`defineReport ${where} "${src}" is not allowed: only plain relative paths (optionally with a ./ prefix) resolve against the report file — no ".." segments, absolute paths, or "~". Move the asset next to the report file and reference it relatively.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
164
191
|
function assertAssets(assets: unknown, field: "scripts" | "styles"): ReportAsset[] {
|
|
165
192
|
if (assets === undefined) return [];
|
|
166
193
|
if (!Array.isArray(assets)) {
|
|
@@ -176,17 +203,111 @@ function assertAssets(assets: unknown, field: "scripts" | "styles"): ReportAsset
|
|
|
176
203
|
}
|
|
177
204
|
if (hasSrc) {
|
|
178
205
|
const src = asset.src as string;
|
|
179
|
-
|
|
180
|
-
if (
|
|
206
|
+
// 外链不属于增强层资产:第三方外链标签的家是 head 通道。
|
|
207
|
+
if (/^https?:\/\//i.test(src) || src.startsWith("//")) {
|
|
181
208
|
throw new Error(
|
|
182
|
-
`defineReport ${field} src "${src}" is
|
|
209
|
+
`defineReport ${field} src "${src}" is an external URL — ${field} take local files and inline content (the host pipeline vendors them). Declare third-party external tags in "head" instead, e.g. head: [{ tag: "script", attrs: { async: true, src: "…" } }].`,
|
|
183
210
|
);
|
|
184
211
|
}
|
|
212
|
+
assertLocalAssetPath(src, `${field} src`);
|
|
185
213
|
}
|
|
186
214
|
}
|
|
187
215
|
return assets as ReportAsset[];
|
|
188
216
|
}
|
|
189
217
|
|
|
218
|
+
const HEAD_TAG_NAMES = new Set(["meta", "link", "script", "style"]);
|
|
219
|
+
const HEAD_ATTR_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_.:-]*$/;
|
|
220
|
+
|
|
221
|
+
function assertHeadTags(tags: unknown): HeadTag[] {
|
|
222
|
+
if (tags === undefined) return [];
|
|
223
|
+
if (!Array.isArray(tags)) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
'defineReport head must be an array of { tag, attrs?, children? } entries (tag: "meta" | "link" | "script" | "style").',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
for (const entry of tags as Array<Record<string, unknown>>) {
|
|
229
|
+
const tag = entry?.tag;
|
|
230
|
+
// 白名单闭集:head 是元数据与第三方脚本的注入口,不是 HTML 后门;标题走 title 字段回退链。
|
|
231
|
+
if (typeof tag !== "string" || !HEAD_TAG_NAMES.has(tag)) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`defineReport head tag ${JSON.stringify(tag)} is not allowed — head injects metadata and third-party tags, and the allowed tags are "meta", "link", "script", "style". For the document title, use the shell "title" field instead.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
const attrs = entry.attrs;
|
|
237
|
+
if (attrs !== undefined && (typeof attrs !== "object" || attrs === null || Array.isArray(attrs))) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`defineReport head <${tag}> attrs must be a { name: string | true } record (true renders a bare boolean attribute like async).`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if ((tag === "meta" || tag === "link") && attrs === undefined) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`defineReport head <${tag}> needs attrs — a bare <${tag}> renders nothing. Declare e.g. { tag: "${tag}", attrs: { ${tag === "meta" ? 'name: "…", content: "…"' : 'rel: "…", href: "…"'} } }.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const attrRecord = (attrs ?? {}) as Record<string, unknown>;
|
|
248
|
+
for (const [name, value] of Object.entries(attrRecord)) {
|
|
249
|
+
if (!HEAD_ATTR_NAME_PATTERN.test(name)) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`defineReport head <${tag}> attribute name ${JSON.stringify(name)} is not a valid HTML attribute name. Use letters, digits, "-", "_", ":" or ".".`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (value !== true && typeof value !== "string") {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`defineReport head <${tag}> attribute "${name}" must be a string or true (true renders a bare boolean attribute like async); got ${typeof value}.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// 宿主自有的文档单例:charset / viewport 由宿主外壳拥有,声明它们装载报错。
|
|
261
|
+
if (tag === "meta" && attrRecord.charset !== undefined) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
"defineReport head must not declare <meta charset> — the document charset is owned by the host shell. Remove the entry.",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (tag === "meta" && typeof attrRecord.name === "string" && attrRecord.name.toLowerCase() === "viewport") {
|
|
267
|
+
throw new Error(
|
|
268
|
+
'defineReport head must not declare <meta name="viewport"> — the viewport is owned by the host shell. Remove the entry.',
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const children = entry.children;
|
|
272
|
+
if (children !== undefined) {
|
|
273
|
+
if (tag === "meta" || tag === "link") {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`defineReport head <${tag}> does not take children — <${tag}> is a void element; put the content in attrs.`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (typeof children !== "string") {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`defineReport head <${tag}> children must be a string of literal ${tag === "script" ? "JavaScript" : "CSS"}; got ${typeof children}.`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
// children 原样落进标签,闭合序列在该上下文无法转义,会提前截断标签。
|
|
284
|
+
if (children.toLowerCase().includes(`</${tag}`)) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`defineReport head <${tag}> children contain "</${tag}>" — that sequence cannot be escaped inside a <${tag}> and would close the tag early. Split the content into two entries or move it into a local file asset.`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// src / href 按 scheme 分流:http(s) 外链原样透传;其余按本地路径纪律解析。
|
|
291
|
+
for (const name of ["src", "href"]) {
|
|
292
|
+
const value = attrRecord[name];
|
|
293
|
+
if (typeof value !== "string") continue;
|
|
294
|
+
if (/^https?:\/\//i.test(value)) continue;
|
|
295
|
+
if (value.startsWith("//")) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`defineReport head <${tag}> ${name} "${value}" is protocol-relative — declare the scheme explicitly, e.g. "https:${value}".`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`defineReport head <${tag}> ${name} "${value}" uses a scheme other than http(s) — external head assets must be http(s) URLs. Anything else, ship as a local file next to the report and reference it relatively.`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
assertLocalAssetPath(value, `head <${tag}> ${name}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return tags as HeadTag[];
|
|
309
|
+
}
|
|
310
|
+
|
|
190
311
|
export function defineReport(content: ReportNode): ReportDefinition;
|
|
191
312
|
export function defineReport(def: ReportDef): ReportDefinition;
|
|
192
313
|
export function defineReport(input: ReportNode | ReportDef): ReportDefinition {
|
|
@@ -196,7 +317,7 @@ export function defineReport(input: ReportNode | ReportDef): ReportDefinition {
|
|
|
196
317
|
: (input as ReportDef);
|
|
197
318
|
if (typeof def !== "object" || def === null) {
|
|
198
319
|
throw new Error(
|
|
199
|
-
"defineReport expects a report tree or a config object ({ title?, links?, footer?, scripts?, styles?, content | pages }). " +
|
|
320
|
+
"defineReport expects a report tree or a config object ({ title?, links?, footer?, head?, scripts?, styles?, content | pages }). " +
|
|
200
321
|
CONTENT_NEXT_STEP,
|
|
201
322
|
);
|
|
202
323
|
}
|
|
@@ -273,6 +394,7 @@ export function defineReport(input: ReportNode | ReportDef): ReportDefinition {
|
|
|
273
394
|
...(def.title !== undefined ? { title: def.title } : {}),
|
|
274
395
|
links: [...links],
|
|
275
396
|
...(def.footer !== undefined ? { footer: def.footer } : {}),
|
|
397
|
+
head: assertHeadTags(def.head),
|
|
276
398
|
scripts: assertAssets(def.scripts, "scripts"),
|
|
277
399
|
styles: assertAssets(def.styles, "styles"),
|
|
278
400
|
pages: pages as unknown as NonEmptyArray<ReportPage>,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// cases: docs/engineering/unit-tests/reports/cases.md
|
|
2
|
+
// 覆盖登记行:外壳 head 通道的白名单/宿主单例/attrs/children/scheme 分流装载校验,
|
|
3
|
+
// 与 scripts {src} 外链拒绝。
|
|
4
|
+
// defineReport 是装载校验的第一期(shell.md「校验分两期」),全部用例不落盘、不进渲染。
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import type { Scope } from "../results/index.ts";
|
|
9
|
+
import { buildReportMeta, defineReport } from "./report.ts";
|
|
10
|
+
|
|
11
|
+
const emptyScope = { snapshots: [] } as unknown as Scope;
|
|
12
|
+
|
|
13
|
+
describe("defineReport head 通道(装载校验)", () => {
|
|
14
|
+
it("tag 白名单是 meta/link/script/style,白名单外装载报错;title 指引到 title 字段", () => {
|
|
15
|
+
expect(() => defineReport({ content: null, head: [{ tag: "base", attrs: {} } as never] })).toThrow(
|
|
16
|
+
/not allowed/,
|
|
17
|
+
);
|
|
18
|
+
expect(() => defineReport({ content: null, head: [{ tag: "title", attrs: {} } as never] })).toThrow(
|
|
19
|
+
/"title" field/,
|
|
20
|
+
);
|
|
21
|
+
expect(() => defineReport({ content: null, head: [{ tag: "iframe", attrs: {} } as never] })).toThrow(
|
|
22
|
+
/meta.*link.*script.*style/,
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("宿主自有单例:meta charset 与 meta name=viewport 装载报错", () => {
|
|
27
|
+
expect(() => defineReport({ content: null, head: [{ tag: "meta", attrs: { charset: "utf-8" } }] })).toThrow(
|
|
28
|
+
/owned by the host shell/,
|
|
29
|
+
);
|
|
30
|
+
expect(() =>
|
|
31
|
+
defineReport({ content: null, head: [{ tag: "meta", attrs: { name: "Viewport", content: "x" } }] }),
|
|
32
|
+
).toThrow(/owned by the host shell/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("children:meta/link 不收;script children 含 </script> 装载报错(该上下文无法转义)", () => {
|
|
36
|
+
expect(() =>
|
|
37
|
+
defineReport({ content: null, head: [{ tag: "link", attrs: { rel: "icon" }, children: "x" } as never] }),
|
|
38
|
+
).toThrow(/void element/);
|
|
39
|
+
expect(() =>
|
|
40
|
+
defineReport({
|
|
41
|
+
content: null,
|
|
42
|
+
head: [{ tag: "script", children: 'document.write("</script>")' }],
|
|
43
|
+
}),
|
|
44
|
+
).toThrow(/cannot be escaped/);
|
|
45
|
+
expect(() =>
|
|
46
|
+
defineReport({ content: null, head: [{ tag: "style", children: "</StYlE>" }] }),
|
|
47
|
+
).toThrow(/cannot be escaped/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("attrs:值只收 string 或 true(裸布尔属性);非法属性名装载报错", () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
defineReport({ content: null, head: [{ tag: "script", attrs: { async: 1 } as never }] }),
|
|
53
|
+
).toThrow(/string or true/);
|
|
54
|
+
expect(() =>
|
|
55
|
+
defineReport({ content: null, head: [{ tag: "meta", attrs: { 'bad name"': "x" } }] }),
|
|
56
|
+
).toThrow(/attribute name/);
|
|
57
|
+
expect(() =>
|
|
58
|
+
defineReport({
|
|
59
|
+
content: null,
|
|
60
|
+
head: [{ tag: "script", attrs: { async: true, "data-project": "p1", src: "https://cdn.example/x.js" } }],
|
|
61
|
+
}),
|
|
62
|
+
).not.toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("src/href 按 scheme 分流:http(s) 外链与本地相对路径合法;protocol-relative 与其它 scheme 装载报错", () => {
|
|
66
|
+
expect(() =>
|
|
67
|
+
defineReport({ content: null, head: [{ tag: "link", attrs: { rel: "icon", href: "./favicon.svg" } }] }),
|
|
68
|
+
).not.toThrow();
|
|
69
|
+
expect(() =>
|
|
70
|
+
defineReport({ content: null, head: [{ tag: "script", attrs: { src: "//cdn.example/x.js" } }] }),
|
|
71
|
+
).toThrow(/protocol-relative/);
|
|
72
|
+
expect(() =>
|
|
73
|
+
defineReport({ content: null, head: [{ tag: "script", attrs: { src: "data:text/javascript,1" } }] }),
|
|
74
|
+
).toThrow(/scheme other than http/);
|
|
75
|
+
expect(() =>
|
|
76
|
+
defineReport({ content: null, head: [{ tag: "link", attrs: { rel: "icon", href: "../up.svg" } }] }),
|
|
77
|
+
).toThrow(/".." segments/);
|
|
78
|
+
expect(() =>
|
|
79
|
+
defineReport({ content: null, head: [{ tag: "link", attrs: { rel: "icon", href: "/abs.svg" } }] }),
|
|
80
|
+
).toThrow(/absolute paths/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("scripts/styles 的 {src} 只收本地路径:外链装载报错并给出 head 写法", () => {
|
|
84
|
+
expect(() => defineReport({ content: null, scripts: [{ src: "https://cdn.example/x.js" }] })).toThrow(
|
|
85
|
+
/Declare third-party external tags in "head"/,
|
|
86
|
+
);
|
|
87
|
+
expect(() => defineReport({ content: null, styles: [{ src: "//fonts.example/a.css" }] })).toThrow(
|
|
88
|
+
/Declare third-party external tags in "head"/,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("规范化:省略 head 恒为空数组,声明原样进产物;head 是注入资产,不进 ctx.report", () => {
|
|
93
|
+
expect(defineReport({ content: null }).head).toEqual([]);
|
|
94
|
+
const head = [
|
|
95
|
+
{ tag: "script" as const, attrs: { async: true as const, src: "https://cdn.example/x.js" } },
|
|
96
|
+
{ tag: "script" as const, children: "window.x = 1;" },
|
|
97
|
+
];
|
|
98
|
+
const definition = defineReport({ content: null, head });
|
|
99
|
+
expect(definition.head).toEqual(head);
|
|
100
|
+
expect(buildReportMeta(definition, emptyScope, "report")).not.toHaveProperty("head");
|
|
101
|
+
});
|
|
102
|
+
});
|
package/src/show/report-host.ts
CHANGED
|
@@ -44,11 +44,21 @@ export interface HostReportLink {
|
|
|
44
44
|
/** `{src}` 与 `{inline}` 两种形态不可同时出现(shell.md「字段穷尽」)。 */
|
|
45
45
|
export type HostReportAsset = { src: string; inline?: never } | { inline: string; src?: never };
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* 结构化 head 标签(shell.md「字段穷尽」):白名单闭集(meta/link/script/style),
|
|
49
|
+
* attrs 值 true 渲染裸布尔属性、字符串转义后渲染 `key="value"`;script/style 的
|
|
50
|
+
* children 原样落进标签。形状与闭合序列在 defineReport 装载期已校验。
|
|
51
|
+
*/
|
|
52
|
+
export type HostHeadTag =
|
|
53
|
+
| { tag: "meta" | "link"; attrs: Record<string, string | true>; children?: never }
|
|
54
|
+
| { tag: "script" | "style"; attrs?: Record<string, string | true>; children?: string };
|
|
55
|
+
|
|
47
56
|
/** 装载规范化产物:外壳 + 非空页列表。show 只消费 title / pages;其余是 web 面属性。 */
|
|
48
57
|
export interface HostReport {
|
|
49
58
|
title?: LocalizedText;
|
|
50
59
|
links: HostReportLink[];
|
|
51
60
|
footer?: LocalizedText;
|
|
61
|
+
head: HostHeadTag[];
|
|
52
62
|
scripts: HostReportAsset[];
|
|
53
63
|
styles: HostReportAsset[];
|
|
54
64
|
pages: HostReportPage[];
|
|
@@ -193,6 +203,7 @@ export function normalizeHostReport(definition: unknown, sourceLabel: string): H
|
|
|
193
203
|
title?: LocalizedText;
|
|
194
204
|
links?: HostReportLink[];
|
|
195
205
|
footer?: LocalizedText;
|
|
206
|
+
head?: HostHeadTag[];
|
|
196
207
|
scripts?: HostReportAsset[];
|
|
197
208
|
styles?: HostReportAsset[];
|
|
198
209
|
content?: unknown;
|
|
@@ -231,6 +242,7 @@ export function normalizeHostReport(definition: unknown, sourceLabel: string): H
|
|
|
231
242
|
...(def.title !== undefined ? { title: def.title } : {}),
|
|
232
243
|
links: def.links ?? [],
|
|
233
244
|
...(def.footer !== undefined ? { footer: def.footer } : {}),
|
|
245
|
+
head: def.head ?? [],
|
|
234
246
|
scripts: def.scripts ?? [],
|
|
235
247
|
styles: def.styles ?? [],
|
|
236
248
|
pages,
|
|
@@ -241,6 +253,7 @@ export function normalizeHostReport(definition: unknown, sourceLabel: string): H
|
|
|
241
253
|
if (isLegacyDefinition(definition)) {
|
|
242
254
|
return {
|
|
243
255
|
links: [],
|
|
256
|
+
head: [],
|
|
244
257
|
scripts: [],
|
|
245
258
|
styles: [],
|
|
246
259
|
pages: [{ id: SINGLE_PAGE_ID, title: BUILT_IN_PAGE_TITLE, content: definition }],
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// cases: docs/engineering/unit-tests/reports/cases.md
|
|
3
|
+
// 「Attempt 详情(view 证据室)」分区——
|
|
4
|
+
// 源码视图是判定与断言的单点:带 loc 的 send 行可点开查看该轮回复(assistant 文本 / thinking),
|
|
5
|
+
// 失败断言行默认展开、展开面直接给 matcher 与 expected / received 的值。
|
|
6
|
+
// 这组测试在 jsdom 里跑真实点击,守住「统一站点管线之后弹窗证据链仍然可用」的行为面
|
|
7
|
+
// (数据侧的字节奇偶由 src/view/site-parity.test.ts 守护,两头合起来 = 线上站点可用)。
|
|
8
|
+
|
|
9
|
+
import { act } from "react";
|
|
10
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
11
|
+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
12
|
+
import { CodeView } from "./CodeView.tsx";
|
|
13
|
+
import { makeTranslator } from "../i18n.ts";
|
|
14
|
+
import type { Assertion, CodeSource, TranscriptEvent } from "../types.ts";
|
|
15
|
+
|
|
16
|
+
declare global {
|
|
17
|
+
// eslint-disable-next-line no-var
|
|
18
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const t = makeTranslator("en");
|
|
26
|
+
|
|
27
|
+
const FILE = "evals/a.eval.ts";
|
|
28
|
+
const SOURCE: CodeSource = {
|
|
29
|
+
path: FILE,
|
|
30
|
+
content: [
|
|
31
|
+
"import { defineEval } from \"niceeval\";",
|
|
32
|
+
"",
|
|
33
|
+
"await t.send(\"do the task\");",
|
|
34
|
+
"",
|
|
35
|
+
"t.check(source, includes(/use cache/));",
|
|
36
|
+
].join("\n"),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const EVENTS: TranscriptEvent[] = [
|
|
40
|
+
{ type: "message", role: "user", text: "do the task", loc: { file: FILE, line: 3 } },
|
|
41
|
+
{ type: "thinking", text: "THINKING_MARKER" },
|
|
42
|
+
{ type: "message", role: "assistant", text: "REPLY_TEXT_MARKER" },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const FAILED_ASSERT: Assertion = {
|
|
46
|
+
name: "Catalog reads use cache",
|
|
47
|
+
detail: "includes(/use cache/)",
|
|
48
|
+
severity: "gate",
|
|
49
|
+
outcome: "failed",
|
|
50
|
+
score: 0,
|
|
51
|
+
expected: "/use cache/",
|
|
52
|
+
received: "RECEIVED_VALUE_MARKER",
|
|
53
|
+
loc: { file: FILE, line: 5 },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
let container: HTMLElement | undefined;
|
|
57
|
+
let root: Root | undefined;
|
|
58
|
+
|
|
59
|
+
function render(ui: React.ReactElement): HTMLElement {
|
|
60
|
+
container = document.createElement("div");
|
|
61
|
+
document.body.appendChild(container);
|
|
62
|
+
root = createRoot(container);
|
|
63
|
+
act(() => root!.render(ui));
|
|
64
|
+
return container;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
if (root) act(() => root!.unmount());
|
|
69
|
+
container?.remove();
|
|
70
|
+
root = undefined;
|
|
71
|
+
container = undefined;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function click(el: Element): void {
|
|
75
|
+
act(() => {
|
|
76
|
+
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 按行号取源码行的 DOM 节点(.code-line 里第一个 .ln 是行号)。 */
|
|
81
|
+
function lineEl(host: HTMLElement, n: number): Element {
|
|
82
|
+
const row = [...host.querySelectorAll(".code-line")].find((el) => el.querySelector(".ln")?.textContent === String(n));
|
|
83
|
+
expect(row, `source line ${n}`).toBeTruthy();
|
|
84
|
+
return row!;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe("CodeView · send 行的回复展开", () => {
|
|
88
|
+
it("带 loc 的 send 行可点开:回复面板显示 assistant 文本与 thinking;再点收起", () => {
|
|
89
|
+
const host = render(<CodeView sources={[SOURCE]} events={EVENTS} assertions={[]} t={t} />);
|
|
90
|
+
|
|
91
|
+
const sendLine = lineEl(host, 3);
|
|
92
|
+
expect(sendLine.className).toContain("line-send");
|
|
93
|
+
// 初始未展开:回复不可见。
|
|
94
|
+
expect(host.textContent).not.toContain("REPLY_TEXT_MARKER");
|
|
95
|
+
|
|
96
|
+
click(sendLine);
|
|
97
|
+
expect(host.querySelector(".reply-panel")).toBeTruthy();
|
|
98
|
+
expect(host.textContent).toContain("REPLY_TEXT_MARKER");
|
|
99
|
+
expect(host.textContent).toContain("THINKING_MARKER");
|
|
100
|
+
|
|
101
|
+
click(lineEl(host, 3));
|
|
102
|
+
expect(host.textContent).not.toContain("REPLY_TEXT_MARKER");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("send 轮没有任何回复事件时,展开面如实显示「无回复」而不是空白", () => {
|
|
106
|
+
const onlySend: TranscriptEvent[] = [EVENTS[0]!];
|
|
107
|
+
const host = render(<CodeView sources={[SOURCE]} events={onlySend} assertions={[]} t={t} />);
|
|
108
|
+
click(lineEl(host, 3));
|
|
109
|
+
expect(host.querySelector(".reply-empty")?.textContent).toBe(t("code.noReply"));
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("CodeView · 断言行的明细展开", () => {
|
|
114
|
+
it("第一条失败断言默认展开:matcher 与 expected / received 的值直接可见,点行可收起", () => {
|
|
115
|
+
const host = render(<CodeView sources={[SOURCE]} events={EVENTS} assertions={[FAILED_ASSERT]} t={t} />);
|
|
116
|
+
|
|
117
|
+
const assertLine = lineEl(host, 5);
|
|
118
|
+
expect(assertLine.className).toContain("line-fail");
|
|
119
|
+
// 默认展开(第一条 failed):不点任何东西就能看到为什么失败。
|
|
120
|
+
expect(host.textContent).toContain("includes(/use cache/)");
|
|
121
|
+
expect(host.textContent).toContain("/use cache/");
|
|
122
|
+
expect(host.textContent).toContain("RECEIVED_VALUE_MARKER");
|
|
123
|
+
|
|
124
|
+
click(assertLine);
|
|
125
|
+
expect(host.textContent).not.toContain("RECEIVED_VALUE_MARKER");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("passed 断言行不默认展开,点开后显示明细", () => {
|
|
129
|
+
const passed: Assertion = { ...FAILED_ASSERT, outcome: "passed", score: 1, loc: { file: FILE, line: 5 } };
|
|
130
|
+
delete (passed as { expected?: string }).expected;
|
|
131
|
+
delete (passed as { received?: string }).received;
|
|
132
|
+
const host = render(<CodeView sources={[SOURCE]} events={EVENTS} assertions={[passed]} t={t} />);
|
|
133
|
+
|
|
134
|
+
const assertLine = lineEl(host, 5);
|
|
135
|
+
expect(assertLine.className).toContain("line-pass");
|
|
136
|
+
expect(host.querySelector(".line-detail")).toBeNull();
|
|
137
|
+
|
|
138
|
+
click(assertLine);
|
|
139
|
+
expect(host.querySelector(".line-detail")).toBeTruthy();
|
|
140
|
+
expect(host.textContent).toContain("includes(/use cache/)");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -4,7 +4,7 @@ import type { T } from "../shared.ts";
|
|
|
4
4
|
import type { Assertion, CodeSource, SourceTurn, TranscriptEvent } from "../types.ts";
|
|
5
5
|
import { highlightTs, indexAsserts, indexTurns, locKey } from "../lib/transcript-data.tsx";
|
|
6
6
|
import { formatScore } from "../lib/format.ts";
|
|
7
|
-
import { InputBlock, ToolBlock, Transcript } from "./Transcript.tsx";
|
|
7
|
+
import { InputBlock, RawEventBlock, ToolBlock, Transcript } from "./Transcript.tsx";
|
|
8
8
|
|
|
9
9
|
/** soft 断言没过阈值不影响 verdict,颜色上跟 gate 失败(红)区分开,用 warn(黄);
|
|
10
10
|
* unavailable 用独立第三态(非红非绿)。 */
|
|
@@ -209,6 +209,13 @@ export function ReplyPanel({ turn, t }: { turn: SourceTurn; t: T }) {
|
|
|
209
209
|
<div className="reply-text">{r.text}</div>
|
|
210
210
|
</div>
|
|
211
211
|
);
|
|
212
|
+
if (r.kind === "user")
|
|
213
|
+
return (
|
|
214
|
+
<div key={j} className="reply-user">
|
|
215
|
+
<span className="reply-role">{t("transcript.user")}</span>
|
|
216
|
+
<div className="reply-text">{r.text}</div>
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
212
219
|
if (r.kind === "thinking")
|
|
213
220
|
return (
|
|
214
221
|
<details key={j} className="reply-think">
|
|
@@ -218,6 +225,13 @@ export function ReplyPanel({ turn, t }: { turn: SourceTurn; t: T }) {
|
|
|
218
225
|
);
|
|
219
226
|
if (r.kind === "error")
|
|
220
227
|
return <div key={j} className="reply-err">! {r.text}</div>;
|
|
228
|
+
if (r.kind === "skill")
|
|
229
|
+
return (
|
|
230
|
+
<div key={j} className="reply-skill">
|
|
231
|
+
<span className="reply-role">{t("transcript.skillLoaded")}</span> {r.skill}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
if (r.kind === "raw") return <RawEventBlock key={j} raw={r.raw} t={t} />;
|
|
221
235
|
if (r.kind === "tool")
|
|
222
236
|
// 和 Transcript 同一个组件:摘要行显示工具名(入参)→ 出参预览,展开看完整出入参。
|
|
223
237
|
return <ToolBlock key={j} call={r.ev} result={r.result} t={t} />;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { T, ToolBlockCall } from "../shared.ts";
|
|
2
|
-
import type { ToolResultEvent, TranscriptEvent } from "../types.ts";
|
|
2
|
+
import type { ObjectRecord, ToolResultEvent, TranscriptEvent } from "../types.ts";
|
|
3
3
|
import { TOOL_VERB, resultBody, toolPrimaryArg } from "../lib/transcript-data.tsx";
|
|
4
4
|
import { prettyJson, previewText, truncate } from "../lib/format.ts";
|
|
5
5
|
|
|
@@ -42,6 +42,15 @@ export function Transcript({ events, t }: { events: TranscriptEvent[]; t: T }) {
|
|
|
42
42
|
);
|
|
43
43
|
case "input.requested":
|
|
44
44
|
return <InputBlock event={event} t={t} key={index} />;
|
|
45
|
+
case "skill.loaded":
|
|
46
|
+
return (
|
|
47
|
+
<div className="ts-skill" key={index}>
|
|
48
|
+
<span className="ts-role">{t("transcript.skillLoaded")}</span>
|
|
49
|
+
<div className="ts-text">{event.skill}</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
case "view.raw":
|
|
53
|
+
return <RawEventBlock raw={event.raw} t={t} key={index} />;
|
|
45
54
|
case "compaction":
|
|
46
55
|
return (
|
|
47
56
|
<div className="ts-compaction" key={index}>
|
|
@@ -62,6 +71,24 @@ export function Transcript({ events, t }: { events: TranscriptEvent[]; t: T }) {
|
|
|
62
71
|
);
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
/** 未识别事件的原样展示:摘要行带原始 type,展开是完整 JSON——不静默丢,方便发现新词汇后续补一等呈现。 */
|
|
75
|
+
export function RawEventBlock({ raw, t }: { raw: ObjectRecord; t: T }) {
|
|
76
|
+
const type = typeof raw.type === "string" ? raw.type : "?";
|
|
77
|
+
const body = prettyJson(raw);
|
|
78
|
+
return (
|
|
79
|
+
<details className="ts-tool-d ts-raw">
|
|
80
|
+
<summary className="ts-row">
|
|
81
|
+
<span className="ts-dot pending" />
|
|
82
|
+
<span className="ts-tool">{type}</span>
|
|
83
|
+
<span className="ts-preview">{t("transcript.rawEvent")}</span>
|
|
84
|
+
</summary>
|
|
85
|
+
<div className="ts-body">
|
|
86
|
+
<pre className="attr-pre">{truncate(body, 8000)}</pre>
|
|
87
|
+
</div>
|
|
88
|
+
</details>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
65
92
|
export function MessageBlock({ event, t }: { event: Extract<TranscriptEvent, { type: "message" }>; t: T }) {
|
|
66
93
|
const who = event.role === "assistant" ? "assistant" : "user";
|
|
67
94
|
return (
|
package/src/view/app/i18n.ts
CHANGED
|
@@ -88,6 +88,8 @@ export type MessageKey =
|
|
|
88
88
|
| "transcript.inputRequested"
|
|
89
89
|
| "transcript.awaitingInput"
|
|
90
90
|
| "transcript.contextCompacted"
|
|
91
|
+
| "transcript.skillLoaded"
|
|
92
|
+
| "transcript.rawEvent"
|
|
91
93
|
| "transcript.running"
|
|
92
94
|
| "transcript.input"
|
|
93
95
|
| "transcript.output"
|
|
@@ -218,6 +220,8 @@ const dictionaries: Record<Locale, Dictionary> = {
|
|
|
218
220
|
"transcript.inputRequested": "input requested",
|
|
219
221
|
"transcript.awaitingInput": "(awaiting input)",
|
|
220
222
|
"transcript.contextCompacted": "context compacted",
|
|
223
|
+
"transcript.skillLoaded": "skill loaded",
|
|
224
|
+
"transcript.rawEvent": "unrecognized event, shown as-is",
|
|
221
225
|
"transcript.running": "running...",
|
|
222
226
|
"transcript.input": "input",
|
|
223
227
|
"transcript.output": "output",
|
|
@@ -343,6 +347,8 @@ const dictionaries: Record<Locale, Dictionary> = {
|
|
|
343
347
|
"transcript.inputRequested": "请求输入",
|
|
344
348
|
"transcript.awaitingInput": "(等待输入)",
|
|
345
349
|
"transcript.contextCompacted": "上下文已压缩",
|
|
350
|
+
"transcript.skillLoaded": "已加载 Skill",
|
|
351
|
+
"transcript.rawEvent": "未识别事件,原样展示",
|
|
346
352
|
"transcript.running": "运行中...",
|
|
347
353
|
"transcript.input": "输入",
|
|
348
354
|
"transcript.output": "输出",
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
// artifact fetch 的 URL
|
|
1
|
+
// artifact fetch 的 URL:以「页面所在目录」为基底的路径 `<页面目录>/artifact/<rel>`。
|
|
2
2
|
// 本地 dev server(server.ts 的 /artifact/ 路由)和目录式静态导出(buildView 拷到 <out>/artifact/)
|
|
3
|
-
//
|
|
3
|
+
// 共用同一布局:artifact/ 恒为 index.html 的同级目录。基底不能交给浏览器的相对解析——
|
|
4
|
+
// 静态托管常把 <dir>/index.html 服务在无尾斜杠的 <dir> 路径上(反代 rewrite、cleanUrls),
|
|
5
|
+
// 此时相对路径 `artifact/...` 会解析到上一级目录断链。这里自己算目录:pathname 末段带 `.`
|
|
6
|
+
// 视为文件名去掉(直接打开 .../index.html),否则整个 pathname 就是目录(含无尾斜杠形态)。
|
|
4
7
|
export function artifactUrl(rel: string): string {
|
|
5
|
-
|
|
8
|
+
const tail = "artifact/" + rel.split("/").map(encodeURIComponent).join("/");
|
|
9
|
+
if (typeof location === "undefined") return tail; // 非浏览器环境(测试直调)保持相对形态
|
|
10
|
+
return pageDir(location.pathname) + tail;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 页面 pathname → 它所在目录(恒以 `/` 结尾)。 */
|
|
14
|
+
export function pageDir(pathname: string): string {
|
|
15
|
+
if (/\.[^/]*$/.test(pathname)) return pathname.replace(/[^/]*$/, "");
|
|
16
|
+
return pathname.endsWith("/") ? pathname : pathname + "/";
|
|
6
17
|
}
|