niceeval 0.7.1 → 0.8.1
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/components.d.ts +4 -5
- package/dist/report/components.js +7 -7
- package/dist/report/compute.d.ts +4 -4
- package/dist/report/compute.js +8 -12
- package/dist/report/index.d.ts +2 -2
- package/dist/report/report.d.ts +23 -1
- package/dist/report/report.js +82 -5
- package/dist/report/types.d.ts +0 -10
- package/dist/results/types.d.ts +0 -11
- package/docs-site/zh/how-to/custom-reports.mdx +3 -3
- package/docs-site/zh/how-to/publish-report.mdx +43 -9
- package/docs-site/zh/how-to/viewing-results.mdx +4 -4
- package/docs-site/zh/reference/cli.mdx +2 -3
- package/docs-site/zh/reference/report-components.mdx +2 -2
- package/docs-site/zh/reference/results-data.mdx +2 -4
- package/docs-site/zh/troubleshooting/debugging.mdx +2 -2
- package/package.json +7 -6
- package/src/cli.ts +1 -5
- package/src/context/context.ts +11 -5
- package/src/report/components.tsx +13 -15
- package/src/report/compute.ts +8 -20
- package/src/report/index.ts +1 -1
- package/src/report/report.test.ts +2 -20
- package/src/report/report.ts +128 -6
- package/src/report/shell-head.test.ts +102 -0
- package/src/report/types.ts +0 -11
- package/src/results/copy.ts +15 -78
- package/src/results/publish.ts +4 -146
- package/src/results/results.test.ts +8 -8
- package/src/results/types.ts +0 -7
- 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/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 +1 -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 -14
- package/src/view/index.ts +18 -58
- package/src/view/server.ts +49 -144
- 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 +6 -6
package/src/view/data.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// 见 docs/feature/reports/view.md「打开与收窄」。
|
|
8
8
|
|
|
9
9
|
import { readFileSync, statSync } from "node:fs";
|
|
10
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
10
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
11
11
|
import { dedupeAttempts, openResults } from "../results/index.ts";
|
|
12
12
|
import type { AttemptHandle, Results, Scope, Snapshot, SkippedDir } from "../results/index.ts";
|
|
13
13
|
import {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
renderHostPageHtml,
|
|
16
16
|
reportMetaFor,
|
|
17
17
|
resolveReportTitle,
|
|
18
|
+
type HostHeadTag,
|
|
18
19
|
type HostReport,
|
|
19
20
|
type HostReportAsset,
|
|
20
21
|
} from "../show/report-host.ts";
|
|
@@ -55,13 +56,7 @@ export interface ViewScan {
|
|
|
55
56
|
*/
|
|
56
57
|
reportPages: ViewReportPageHtml[];
|
|
57
58
|
/** 外壳注入资产(styles / scripts;{src} 已按路径纪律解析成 inline 内容),只进 web 面。 */
|
|
58
|
-
shellAssets: { styles: string[]; scripts: string[] };
|
|
59
|
-
/**
|
|
60
|
-
* --out 的数据等级(见 docs/feature/reports/view.md「静态导出」):全部选中快照带
|
|
61
|
-
* publish:{redaction:"applied"} 才是 "applied",否则 "sensitive"(含本地事实根与
|
|
62
|
-
* redaction:"none"——上游声明过原文发布也不豁免导出时的确认)。
|
|
63
|
-
*/
|
|
64
|
-
publishState: "applied" | "sensitive";
|
|
59
|
+
shellAssets: { styles: string[]; scripts: string[]; head: ResolvedHeadTag[] };
|
|
65
60
|
}
|
|
66
61
|
|
|
67
62
|
/** view 宿主输入的组合语义(与 show 对齐,docs/feature/reports/architecture.md「Scope 是计算入口」)。 */
|
|
@@ -291,17 +286,12 @@ export async function loadViewScan(input?: string, opts: ViewScanOptions = {}):
|
|
|
291
286
|
skippedRuns: results.skipped.map(toSkippedNotice),
|
|
292
287
|
report: slot.meta,
|
|
293
288
|
};
|
|
294
|
-
const publishState =
|
|
295
|
-
selection.snapshots.length > 0 && selection.snapshots.every((snap) => snap.publish?.redaction === "applied")
|
|
296
|
-
? ("applied" as const)
|
|
297
|
-
: ("sensitive" as const);
|
|
298
289
|
return {
|
|
299
290
|
viewData,
|
|
300
291
|
artifactDirs,
|
|
301
292
|
attemptsByBase,
|
|
302
293
|
reportPages: slot.pages,
|
|
303
294
|
shellAssets: slot.shellAssets,
|
|
304
|
-
publishState,
|
|
305
295
|
};
|
|
306
296
|
}
|
|
307
297
|
|
|
@@ -331,6 +321,51 @@ function escapeHtml(text: string): string {
|
|
|
331
321
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
332
322
|
}
|
|
333
323
|
|
|
324
|
+
/**
|
|
325
|
+
* head 标签的解析形态:attrs 里的本地 `src` / `href` 已解析成宿主机绝对路径,
|
|
326
|
+
* 由站点管线(site.ts)按内容哈希物化为 `assets/<sha256><ext>` 并回填该属性;
|
|
327
|
+
* 外链与无资产标签原样透传(shell.md「行为约束」)。
|
|
328
|
+
*/
|
|
329
|
+
export interface ResolvedHeadTag {
|
|
330
|
+
tag: "meta" | "link" | "script" | "style";
|
|
331
|
+
attrs: Record<string, string | true>;
|
|
332
|
+
children?: string;
|
|
333
|
+
localAsset?: { attr: "src" | "href"; abs: string; ext: string };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** head 标签 attrs 的 src/href 解析:外链透传,本地路径按 `{src}` 同一路径纪律落成绝对路径并验存在。 */
|
|
337
|
+
function resolveShellHead(tags: HostHeadTag[], baseDir: string | undefined): ResolvedHeadTag[] {
|
|
338
|
+
const out: ResolvedHeadTag[] = [];
|
|
339
|
+
for (const entry of tags) {
|
|
340
|
+
const attrs = { ...(entry.attrs ?? {}) };
|
|
341
|
+
const resolved: ResolvedHeadTag = {
|
|
342
|
+
tag: entry.tag,
|
|
343
|
+
attrs,
|
|
344
|
+
...(entry.children !== undefined ? { children: entry.children } : {}),
|
|
345
|
+
};
|
|
346
|
+
for (const attr of ["src", "href"] as const) {
|
|
347
|
+
const value = attrs[attr];
|
|
348
|
+
if (typeof value !== "string" || /^https?:\/\//i.test(value)) continue;
|
|
349
|
+
// 形状与 scheme 已在 defineReport 装载期校验;这里只剩本地文件的存在性。
|
|
350
|
+
if (value.startsWith("/") || value.startsWith("~") || value.split("/").includes("..")) {
|
|
351
|
+
throw new ViewInputError(
|
|
352
|
+
`Report head <${entry.tag}> ${attr} "${value}" is not a plain relative path. Assets resolve relative to the report file; ".." segments, absolute paths and "~" are not allowed.`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const abs = resolve(baseDir ?? process.cwd(), value);
|
|
356
|
+
try {
|
|
357
|
+
statSync(abs);
|
|
358
|
+
} catch {
|
|
359
|
+
throw new ViewInputError(`Report head <${entry.tag}> ${attr} asset not found: ${abs} (declared as "${value}").`);
|
|
360
|
+
}
|
|
361
|
+
resolved.localAsset = { attr, abs, ext: extname(abs) };
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
out.push(resolved);
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
334
369
|
/** 外壳 `{src}` 资产的路径纪律(shell.md「行为约束」):相对报告文件解析;拒绝 `..` 路径段、绝对路径与 `~`。 */
|
|
335
370
|
function resolveShellAssets(assets: HostReportAsset[], baseDir: string | undefined, kind: "styles" | "scripts"): string[] {
|
|
336
371
|
const out: string[] = [];
|
|
@@ -371,7 +406,11 @@ async function renderReportSlot(
|
|
|
371
406
|
results: Results,
|
|
372
407
|
selection: Scope,
|
|
373
408
|
pageFailure: "throw" | "embed" = "throw",
|
|
374
|
-
): Promise<{
|
|
409
|
+
): Promise<{
|
|
410
|
+
meta: ViewReportMeta;
|
|
411
|
+
pages: ViewReportPageHtml[];
|
|
412
|
+
shellAssets: { styles: string[]; scripts: string[]; head: ResolvedHeadTag[] };
|
|
413
|
+
}> {
|
|
375
414
|
// 报告 runtime 走预编译产物(dist/report/**,`pnpm run build:report` 产出),不受 view
|
|
376
415
|
// 消费方 cwd/tsconfig 影响;装载与渲染统一经 ../show/report-host.ts(两个宿主共用的联系面)。
|
|
377
416
|
const hostReport: HostReport = await loadHostReport(report?.cwd ?? process.cwd(), report?.path, {
|
|
@@ -389,6 +428,7 @@ async function renderReportSlot(
|
|
|
389
428
|
const shellAssets = {
|
|
390
429
|
styles: resolveShellAssets(hostReport.styles, reportDir, "styles"),
|
|
391
430
|
scripts: resolveShellAssets(hostReport.scripts, reportDir, "scripts"),
|
|
431
|
+
head: resolveShellHead(hostReport.head, reportDir),
|
|
392
432
|
};
|
|
393
433
|
|
|
394
434
|
const pages: ViewReportPageHtml[] = [];
|
package/src/view/index.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// 本地结果查看器入口:只做编排与对外导出。
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
// 站点管线(planSite/writeSite,server 与 --out 的唯一联系面)在 site.ts,读取(openResults)
|
|
3
|
+
// 与统计(官方计算函数)在 data.ts,HTTP 宿主在 server.ts,server/前端共用的数据契约在
|
|
4
|
+
// shared/types.ts。
|
|
4
5
|
|
|
5
|
-
import { copyFile, mkdir, writeFile } from "node:fs/promises";
|
|
6
6
|
import { existsSync, statSync } from "node:fs";
|
|
7
|
-
import {
|
|
8
|
-
import { ViewInputError
|
|
9
|
-
import {
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { ViewInputError } from "./data.ts";
|
|
9
|
+
import { planSite, writeSite } from "./site.ts";
|
|
10
|
+
import type { ViewOptions } from "./server.ts";
|
|
10
11
|
|
|
11
12
|
export { startViewServer, type ViewOptions, type ViewServer } from "./server.ts";
|
|
13
|
+
export { planSite, writeSite, renderHtml, type SitePlan, type SiteFile } from "./site.ts";
|
|
12
14
|
export {
|
|
13
15
|
IncompatibleResultsError,
|
|
14
16
|
ViewInputError,
|
|
@@ -64,12 +66,12 @@ export function resolveViewInput(
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
|
-
* 导出静态报告(--out)
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* 导出静态报告(--out):只有目录式一种形态。站点管线(site.ts)产出与本地 server 服务的
|
|
70
|
+
* 同一份产物清单,这里把它写进 <dir>——index.html + `artifact/<base>/` 证据树,整个目录扔给
|
|
71
|
+
* 任何静态托管即是完整体验。首页即报告槽(裸跑填充内建报告,--report 整槽替换),证据室同站;
|
|
72
|
+
* 多页报告仍是单个 index.html(页面走 `#/page/<id>` 路由)。单文件(*.html)导出已移除:
|
|
73
|
+
* 代码/transcript/trace 视图依赖 artifact 文件,单文件注定残缺(docs/feature/reports/view.md
|
|
74
|
+
* 「静态导出」)。
|
|
73
75
|
*/
|
|
74
76
|
export async function buildView(opts: ViewOptions = {}): Promise<string> {
|
|
75
77
|
const out = resolve(opts.out ?? ".niceeval/site");
|
|
@@ -86,54 +88,12 @@ export async function buildView(opts: ViewOptions = {}): Promise<string> {
|
|
|
86
88
|
"--out exports the whole results root and cannot be combined with eval prefixes or --experiment.\n" +
|
|
87
89
|
"To publish a site for one experiment, build a narrower results root and export that:\n" +
|
|
88
90
|
' const results = await openResults(".niceeval");\n' +
|
|
89
|
-
' await copySnapshots(results.latest().filter((s) => s.experimentId.startsWith("<prefix>/")), "<publish-root>"
|
|
91
|
+
' await copySnapshots(results.latest().filter((s) => s.experimentId.startsWith("<prefix>/")), "<publish-root>");\n' +
|
|
90
92
|
"Then: niceeval view --results <publish-root> --out <site>",
|
|
91
93
|
);
|
|
92
94
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
// 无标记结果或本地事实根,都必须显式传 --allow-sensitive-artifacts——静态站原样携带证据文件,
|
|
97
|
-
// 上游声明过原文发布也不豁免这里的确认。
|
|
98
|
-
if (!opts.allowSensitiveArtifacts && scan.publishState !== "applied") {
|
|
99
|
-
throw new ViewInputError(
|
|
100
|
-
`--out refuses to export unredacted results: not every selected snapshot carries publish: { redaction: "applied" }. ` +
|
|
101
|
-
`Produce a publish root first with copySnapshots({ redact }) and export that (niceeval view --results <publish-root> --out <site>), ` +
|
|
102
|
-
`or pass --allow-sensitive-artifacts to explicitly export raw evidence (prompts, tool args, full outputs, sources).`,
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
await mkdir(out, { recursive: true });
|
|
106
|
-
await writeFile(join(out, "index.html"), await renderHtml(scan), "utf-8");
|
|
107
|
-
await copyFetchedArtifacts(scan, join(out, "artifact"));
|
|
95
|
+
// 静态导出保持「任一页失败整体失败」(pageFailure 缺省 "throw"),不产出半套站点。
|
|
96
|
+
const plan = await planSite(opts.input, opts.scan);
|
|
97
|
+
await writeSite(plan, out);
|
|
108
98
|
return out;
|
|
109
99
|
}
|
|
110
|
-
|
|
111
|
-
// 导出没有档位(docs/feature/reports/view.md「静态导出」):结果根里存在且前端会读取的证据
|
|
112
|
-
// 文件全部复制——events.json / trace.json / diff.json 原字节复制(diff 有就带,缺时前端在
|
|
113
|
-
// 证据位置如实显示缺失;体积取舍在构建发布根时用 copySnapshots({ artifacts }) 做,不在导出层);
|
|
114
|
-
// 唯一永不复制的是 o11y.json——报告数字在导出时已烘进 HTML,浏览器不读它。
|
|
115
|
-
// sources.json 是格式例外——盘上是去重后的引用(`{path, sha256}[]`),必须先经
|
|
116
|
-
// AttemptHandle.sources() 解引用出完整内容(`{path, content}[]`)再写出,否则浏览器端的
|
|
117
|
-
// isCodeSource 守卫因缺 content 字段判空,代码视图会误判「源码未捕获」(即便源码明明捕获了)。
|
|
118
|
-
const RAW_COPY_ARTIFACTS = ["events.json", "trace.json", "diff.json"];
|
|
119
|
-
|
|
120
|
-
async function copyFetchedArtifacts(scan: ViewScan, artifactRoot: string): Promise<void> {
|
|
121
|
-
for (const [base, srcDir] of scan.artifactDirs) {
|
|
122
|
-
const destDir = join(artifactRoot, base);
|
|
123
|
-
// 输入本身已经是导出布局(比如对着上次导出的目录重新生成 index.html)时不自拷。
|
|
124
|
-
if (resolve(srcDir) === resolve(destDir)) continue;
|
|
125
|
-
|
|
126
|
-
const rawFiles = RAW_COPY_ARTIFACTS.filter((name) => existsSync(join(srcDir, name)));
|
|
127
|
-
const hasSourcesRef = existsSync(join(srcDir, "sources.json"));
|
|
128
|
-
if (!rawFiles.length && !hasSourcesRef) continue;
|
|
129
|
-
|
|
130
|
-
await mkdir(destDir, { recursive: true });
|
|
131
|
-
await Promise.all(rawFiles.map((name) => copyFile(join(srcDir, name), join(destDir, name))));
|
|
132
|
-
|
|
133
|
-
if (hasSourcesRef) {
|
|
134
|
-
const attempt = scan.attemptsByBase.get(base);
|
|
135
|
-
const sources = attempt ? await attempt.sources() : null;
|
|
136
|
-
await writeFile(join(destDir, "sources.json"), JSON.stringify(sources ?? []), "utf-8");
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
package/src/view/server.ts
CHANGED
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
// HTTP server
|
|
2
|
-
//
|
|
1
|
+
// HTTP server:把站点管线(site.ts 的 planSite)产出的同一份产物挂在 127.0.0.1 上按路径服务。
|
|
2
|
+
// 这里不携带任何取数或布局知识——查不到清单条目就是 404,与 `--out` 写盘的文件逐字节一致
|
|
3
|
+
// (docs/feature/reports/view.md 开篇;奇偶由 site-parity 测试守护)。宿主语义只有三条,全部
|
|
4
|
+
// 作用在管线输入端:打开首页整份重建(数据永远是盘上最新)、单页渲染失败折成页内错误块
|
|
5
|
+
// (pageFailure: "embed")、报告槽可被位置参数 / --experiment 收窄。
|
|
3
6
|
|
|
4
7
|
import { createServer, type Server } from "node:http";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { loadAttemptIndex, loadViewScan, viewRoot, type ViewScan, type ViewScanOptions } from "./data.ts";
|
|
8
|
-
import type { AttemptHandle } from "../results/index.ts";
|
|
9
|
-
import { localizeText } from "../show/report-host.ts";
|
|
8
|
+
import { type ViewScanOptions } from "./data.ts";
|
|
9
|
+
import { planSite, readSiteFile, type SitePlan } from "./site.ts";
|
|
10
10
|
import { formatThrown } from "../util.ts";
|
|
11
11
|
|
|
12
12
|
export interface ViewOptions {
|
|
13
13
|
input?: string;
|
|
14
14
|
out?: string;
|
|
15
15
|
port?: number;
|
|
16
|
-
/**
|
|
17
|
-
allowSensitiveArtifacts?: boolean;
|
|
18
|
-
/** 报告槽的组合语义(位置前缀 / --experiment / --report),透传给 loadViewScan。 */
|
|
16
|
+
/** 报告槽的组合语义(位置前缀 / --experiment / --report),透传给站点管线。 */
|
|
19
17
|
scan?: ViewScanOptions;
|
|
20
18
|
}
|
|
21
19
|
|
|
@@ -24,21 +22,22 @@ export interface ViewServer {
|
|
|
24
22
|
close(): Promise<void>;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
const TEMPLATE_PLACEHOLDERS = {
|
|
28
|
-
styles: "<!-- __NICEEVAL_STYLES__ -->",
|
|
29
|
-
appCode: "__NICEEVAL_APP_CODE__",
|
|
30
|
-
viewData: "__NICEEVAL_VIEW_DATA_JSON__",
|
|
31
|
-
reportSlot: "<!-- __NICEEVAL_REPORT_SLOT__ -->",
|
|
32
|
-
} as const;
|
|
33
|
-
|
|
34
25
|
export async function startViewServer(opts: ViewOptions = {}): Promise<ViewServer> {
|
|
35
26
|
const input = opts.input;
|
|
36
|
-
const root = viewRoot(input);
|
|
37
27
|
// 本地 server 的单页失败折成该页的错误块,其它页照常可读(静态导出仍整体失败)。
|
|
38
28
|
const scanOptions = { ...opts.scan, pageFailure: "embed" as const };
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
29
|
+
|
|
30
|
+
// 产物重建的单飞通道:首页请求整份重建;并发请求共享同一次构建,不重复扫描。
|
|
31
|
+
let current: Promise<SitePlan>;
|
|
32
|
+
const rebuild = (): Promise<SitePlan> => {
|
|
33
|
+
current = planSite(input, scanOptions);
|
|
34
|
+
return current;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// 启动前先构建一遍:--snapshot 指向读不了的快照、--report 装载失败、前缀匹配不到,
|
|
38
|
+
// 都要在起 server 前就失败并给出提示。
|
|
39
|
+
await rebuild();
|
|
40
|
+
|
|
42
41
|
const server = createServer(async (req, res) => {
|
|
43
42
|
try {
|
|
44
43
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
@@ -47,30 +46,42 @@ export async function startViewServer(opts: ViewOptions = {}): Promise<ViewServe
|
|
|
47
46
|
res.end("ok");
|
|
48
47
|
return;
|
|
49
48
|
}
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
|
|
50
|
+
// 站点相对路径:`/` 即 index.html;兼容旧的 /artifact?p= query 形式
|
|
51
|
+
// (0.2.x 前端烘焙的 HTML 可能还开着)。
|
|
52
|
+
let sitePath: string;
|
|
53
|
+
if (url.pathname === "/") {
|
|
54
|
+
// 每次打开首页整份重建,永远是盘上最新数据;--report 的报告文件变更同样在
|
|
55
|
+
// 下次请求整页重算(装载走 mtime cache-busting,见 report/load.ts)。
|
|
56
|
+
await rebuild();
|
|
57
|
+
sitePath = "index.html";
|
|
58
|
+
} else if (url.pathname === "/artifact") {
|
|
59
|
+
sitePath = `artifact/${url.searchParams.get("p") ?? ""}`;
|
|
60
|
+
} else {
|
|
61
|
+
sitePath = decodeURIComponent(url.pathname.slice(1));
|
|
56
62
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
|
|
64
|
+
let plan = await current;
|
|
65
|
+
let file = plan.files.get(sitePath);
|
|
66
|
+
if (!file && sitePath.startsWith("artifact/")) {
|
|
67
|
+
// 未命中最近一次构建的产物清单:管线重建一次再查——server 运行期间
|
|
68
|
+
// 新落盘的证据(新快照、补跑)不需要重启。
|
|
69
|
+
plan = await rebuild();
|
|
70
|
+
file = plan.files.get(sitePath);
|
|
71
|
+
}
|
|
72
|
+
if (!file) {
|
|
73
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
74
|
+
res.end("not found");
|
|
60
75
|
return;
|
|
61
76
|
}
|
|
62
|
-
|
|
77
|
+
const body = await readSiteFile(file);
|
|
78
|
+
if (body === undefined) {
|
|
63
79
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
64
80
|
res.end("not found");
|
|
65
81
|
return;
|
|
66
82
|
}
|
|
67
|
-
res.writeHead(200, {
|
|
68
|
-
|
|
69
|
-
"cache-control": "no-store",
|
|
70
|
-
});
|
|
71
|
-
// 每次请求现读现算,永远是盘上最新数据;--report 的报告文件变更同样在
|
|
72
|
-
// 下次请求整页重算(装载走 mtime cache-busting,见 report/load.ts)。
|
|
73
|
-
res.end(await renderHtml(await loadViewScan(input, scanOptions)));
|
|
83
|
+
res.writeHead(200, { "content-type": file.contentType, "cache-control": "no-store" });
|
|
84
|
+
res.end(body);
|
|
74
85
|
} catch (e) {
|
|
75
86
|
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
76
87
|
res.end(formatThrown(e));
|
|
@@ -87,112 +98,6 @@ export async function startViewServer(opts: ViewOptions = {}): Promise<ViewServe
|
|
|
87
98
|
};
|
|
88
99
|
}
|
|
89
100
|
|
|
90
|
-
/**
|
|
91
|
-
* 把 viewData(只含原始值与相对路径,不含宿主机绝对路径)和前端产物烘焙进单个 HTML。
|
|
92
|
-
* 报告槽恒在:每页报告 HTML 作为 <template id="niceeval-report-<pageId>-<locale>"> 静态块
|
|
93
|
-
* 烘在 __NICEEVAL_VIEW_DATA__ 旁(不 hydrate,自定义组件的 <Style> 产物已内联其中),
|
|
94
|
-
* 并恒内联官方组件样式(report/react/styles.css)与渐进增强 runtime(report/react/enhance.js,
|
|
95
|
-
* 内联 <script>:排序 / 过滤 / tooltip,document 级事件委托,报告块被前端搬进槽位也无需重绑;
|
|
96
|
-
* 无 JS 时报告内容依旧完整);外壳声明的 styles 注入在官方样式之后、scripts 注入在官方
|
|
97
|
-
* 增强脚本之后 </body> 前,均按声明顺序(docs/feature/reports/library/shell.md)。
|
|
98
|
-
* 前端只把当前页 / 当前界面语言对应的块摆进报告槽位置,不解析。
|
|
99
|
-
*/
|
|
100
|
-
export async function renderHtml(scan: ViewScan): Promise<string> {
|
|
101
|
-
const template = await readViewAsset("template.html");
|
|
102
|
-
const styles = await readViewAsset("client-dist/app.css");
|
|
103
|
-
const app = await readViewAsset("client-dist/app.js");
|
|
104
|
-
const [reportStyles, reportEnhance] = await Promise.all([
|
|
105
|
-
readFile(new URL("../report/react/styles.css", import.meta.url), "utf-8"),
|
|
106
|
-
readFile(new URL("../report/react/enhance.js", import.meta.url), "utf-8"),
|
|
107
|
-
]);
|
|
108
|
-
|
|
109
|
-
const shellStyles = scan.shellAssets.styles.map((css) => `\n<style>\n${css}\n</style>`).join("");
|
|
110
|
-
const shellScripts = scan.shellAssets.scripts.map((js) => `<script>\n${js}\n</script>\n`).join("");
|
|
111
|
-
|
|
112
|
-
const pageTemplates = scan.reportPages
|
|
113
|
-
.flatMap((page) => [
|
|
114
|
-
`<template id="niceeval-report-${page.id}-en">${page.html.en}</template>`,
|
|
115
|
-
`<template id="niceeval-report-${page.id}-zh-CN">${page.html["zh-CN"]}</template>`,
|
|
116
|
-
])
|
|
117
|
-
.join("\n");
|
|
118
|
-
|
|
119
|
-
// 初始 <title> 与 hero 同源:走完回退链的报告标题(viewData.report.title;终点是内置文案
|
|
120
|
-
// 「Eval 运行结果 / Eval Results」)。模板 lang="en",初始按 en 解析;前端按界面语言更新。
|
|
121
|
-
const title = localizeText(scan.viewData.report?.title, "en") ?? "Eval Results";
|
|
122
|
-
|
|
123
|
-
return template
|
|
124
|
-
.replace(
|
|
125
|
-
/<title>[^<]*<\/title>/,
|
|
126
|
-
() => `<title>${title.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</title>`,
|
|
127
|
-
)
|
|
128
|
-
.replace(
|
|
129
|
-
TEMPLATE_PLACEHOLDERS.styles,
|
|
130
|
-
() =>
|
|
131
|
-
`<style>\n${styles}\n</style>\n<style>\n${reportStyles}\n</style>\n<script>\n${reportEnhance}\n</script>` +
|
|
132
|
-
shellStyles,
|
|
133
|
-
)
|
|
134
|
-
.replace(TEMPLATE_PLACEHOLDERS.reportSlot, () => pageTemplates)
|
|
135
|
-
.replace(TEMPLATE_PLACEHOLDERS.viewData, () => JSON.stringify(scan.viewData).replace(/</g, "\\u003c"))
|
|
136
|
-
.replace(TEMPLATE_PLACEHOLDERS.appCode, () => JSON.stringify(app).replace(/</g, "\\u003c"))
|
|
137
|
-
.replace("</body>", () => `${shellScripts}</body>`);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** rel 的末段文件名;base = 去掉末段后剩下的部分(与 artifactUrl 的按段编码/拼接对称)。 */
|
|
141
|
-
function splitArtifactRel(rel: string): { base: string; file: string } {
|
|
142
|
-
const segments = rel.split("/");
|
|
143
|
-
const file = segments.pop() ?? "";
|
|
144
|
-
return { base: segments.join("/"), file };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* 安全地把 root 下的 artifact 文件吐回去(限定 .json,且解析后必须仍在 root 内)。
|
|
149
|
-
* sources.json 是唯一例外:落盘是去重后的引用(`{path, sha256}[]`),不能直接 piping 原字节——
|
|
150
|
-
* 按 base 反查 AttemptHandle,经它的 `.sources()` 解引用出完整内容(`{path, content}[]`)再回给
|
|
151
|
-
* 浏览器。attemptIndex 懒建(只在真的碰到 sources.json 请求时才付 openResults() 扫描的代价),
|
|
152
|
-
* events.json / trace.json 等其它 artifact 完全不受影响,原样走原文件读取。
|
|
153
|
-
*/
|
|
154
|
-
async function serveArtifact(
|
|
155
|
-
root: string,
|
|
156
|
-
rel: string,
|
|
157
|
-
res: import("node:http").ServerResponse,
|
|
158
|
-
attemptIndex: () => Promise<Map<string, AttemptHandle>>,
|
|
159
|
-
): Promise<void> {
|
|
160
|
-
const abs = resolve(root, rel);
|
|
161
|
-
const within = abs === root || abs.startsWith(root + "/");
|
|
162
|
-
if (!within || !rel.endsWith(".json")) {
|
|
163
|
-
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
164
|
-
res.end("bad artifact path");
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const { base, file } = splitArtifactRel(rel);
|
|
169
|
-
if (file === "sources.json") {
|
|
170
|
-
const attempt = (await attemptIndex()).get(base);
|
|
171
|
-
if (!attempt) {
|
|
172
|
-
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
173
|
-
res.end("artifact not found");
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
const sources = await attempt.sources();
|
|
177
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
178
|
-
res.end(JSON.stringify(sources ?? []));
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
try {
|
|
183
|
-
const body = await readFile(abs, "utf-8");
|
|
184
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
185
|
-
res.end(body);
|
|
186
|
-
} catch {
|
|
187
|
-
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
188
|
-
res.end("artifact not found");
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
async function readViewAsset(name: string): Promise<string> {
|
|
193
|
-
return readFile(new URL(name, import.meta.url), "utf-8");
|
|
194
|
-
}
|
|
195
|
-
|
|
196
101
|
async function listen(server: Server, preferredPort: number): Promise<number> {
|
|
197
102
|
const tryListen = (port: number): Promise<number> =>
|
|
198
103
|
new Promise((resolveListen, reject) => {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// cases: docs/engineering/unit-tests/reports/cases.md
|
|
2
|
+
// 覆盖登记行:head 通道注入面——声明序注入 <head>、外链原样透传不进 assets/、
|
|
3
|
+
// 本地 src/href 物化为 assets/<sha256><ext>、attrs 渲染与转义、注入不改初始 HTML
|
|
4
|
+
// 数据节点、head 不进 ctx.report / viewData。
|
|
5
|
+
// 站点管线是 server 与 --out 的唯一联系面,断言全部打在 planSite 产物上。
|
|
6
|
+
|
|
7
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { loadViewScan } from "./data.ts";
|
|
14
|
+
import { planSite, readSiteFile, writeSite } from "./site.ts";
|
|
15
|
+
import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION } from "../types.ts";
|
|
16
|
+
|
|
17
|
+
const roots: string[] = [];
|
|
18
|
+
async function makeRoot(): Promise<string> {
|
|
19
|
+
const root = await mkdtemp(join(tmpdir(), "niceeval-sitehead-"));
|
|
20
|
+
roots.push(root);
|
|
21
|
+
return root;
|
|
22
|
+
}
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** 最小结果根:单实验单快照单 attempt(与 view-report.test.ts 的 writeSnapshot 同一姿势)。 */
|
|
28
|
+
async function seedRoot(): Promise<string> {
|
|
29
|
+
const root = await makeRoot();
|
|
30
|
+
const dir = join(root, "compare_bub", "2026-07-08T10-00-00-000Z");
|
|
31
|
+
await mkdir(dir, { recursive: true });
|
|
32
|
+
await writeFile(
|
|
33
|
+
join(dir, "snapshot.json"),
|
|
34
|
+
JSON.stringify({
|
|
35
|
+
format: RESULTS_FORMAT,
|
|
36
|
+
schemaVersion: RESULTS_SCHEMA_VERSION,
|
|
37
|
+
producer: { name: "niceeval", version: "0.4.6" },
|
|
38
|
+
experimentId: "compare/bub",
|
|
39
|
+
agent: "bub",
|
|
40
|
+
startedAt: "2026-07-08T10:00:00.000Z",
|
|
41
|
+
completedAt: "2026-07-08T10:00:00.000Z",
|
|
42
|
+
}),
|
|
43
|
+
"utf-8",
|
|
44
|
+
);
|
|
45
|
+
const attemptDir = join(dir, "weather/brooklyn", "a0");
|
|
46
|
+
await mkdir(attemptDir, { recursive: true });
|
|
47
|
+
await writeFile(
|
|
48
|
+
join(attemptDir, "result.json"),
|
|
49
|
+
JSON.stringify({ id: "weather/brooklyn", verdict: "passed", attempt: 0, durationMs: 1000, assertions: [] }),
|
|
50
|
+
"utf-8",
|
|
51
|
+
);
|
|
52
|
+
return root;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const FAVICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="4"/></svg>';
|
|
56
|
+
|
|
57
|
+
const GA_SRC = "https://www.googletagmanager.com/gtag/js?id=G-TEST";
|
|
58
|
+
const OG_IMAGE = 'https://x.example/a"b.png';
|
|
59
|
+
|
|
60
|
+
/** 不经包入口也合法的最小外壳报告(与 view-report.test.ts 的 shellReportSource 同一姿势)。 */
|
|
61
|
+
function headReportSource(head: string): string {
|
|
62
|
+
return [
|
|
63
|
+
'const FACES = Symbol.for("niceeval.report.faces");',
|
|
64
|
+
'const DEFINITION = Symbol.for("niceeval.report.definition");',
|
|
65
|
+
"const Block = (props) => Block[FACES].web(props);",
|
|
66
|
+
"Block[FACES] = {",
|
|
67
|
+
' web: () => "HEAD_TEST_BODY",',
|
|
68
|
+
' text: () => "HEAD_TEST_BODY",',
|
|
69
|
+
"};",
|
|
70
|
+
"const definition = {",
|
|
71
|
+
' kind: "report",',
|
|
72
|
+
' title: "Head Test",',
|
|
73
|
+
" links: [],",
|
|
74
|
+
` head: ${head},`,
|
|
75
|
+
" scripts: [],",
|
|
76
|
+
" styles: [],",
|
|
77
|
+
' pages: [{ id: "report", title: "Report", content: { $$typeof: Symbol.for("react.transitional.element"), type: Block, props: {}, key: null } }],',
|
|
78
|
+
"};",
|
|
79
|
+
"Object.defineProperty(definition, DEFINITION, { value: true });",
|
|
80
|
+
"export default definition;",
|
|
81
|
+
"",
|
|
82
|
+
].join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const HEAD_DECL = JSON.stringify([
|
|
86
|
+
{ tag: "script", attrs: { async: true, src: GA_SRC } },
|
|
87
|
+
{ tag: "script", children: "window.dataLayer = window.dataLayer || [];" },
|
|
88
|
+
{ tag: "meta", attrs: { property: "og:image", content: OG_IMAGE } },
|
|
89
|
+
{ tag: "link", attrs: { rel: "icon", href: "./favicon.svg" } },
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
async function seedReport(root: string, head: string): Promise<string> {
|
|
93
|
+
const path = join(root, "head-report.mjs");
|
|
94
|
+
await writeFile(path, headReportSource(head), "utf-8");
|
|
95
|
+
await writeFile(join(root, "favicon.svg"), FAVICON_SVG, "utf-8");
|
|
96
|
+
return path;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("站点管线 · head 通道注入", () => {
|
|
100
|
+
it("按声明序注入 <head>:外链原样、布尔属性裸渲染、属性值转义、children 原样", async () => {
|
|
101
|
+
const root = await seedRoot();
|
|
102
|
+
const path = await seedReport(root, HEAD_DECL);
|
|
103
|
+
const plan = await planSite(root, { report: { path, cwd: root } });
|
|
104
|
+
const html = (await readSiteFile(plan.files.get("index.html")!)) as string;
|
|
105
|
+
|
|
106
|
+
const gtag = `<script async src="${GA_SRC}"></script>`;
|
|
107
|
+
const inline = "<script>window.dataLayer = window.dataLayer || [];</script>";
|
|
108
|
+
const og = '<meta property="og:image" content="https://x.example/a"b.png">';
|
|
109
|
+
expect(html).toContain(gtag);
|
|
110
|
+
expect(html).toContain(inline);
|
|
111
|
+
expect(html).toContain(og);
|
|
112
|
+
// 全部落在 <head> 内、按声明序。
|
|
113
|
+
const headEnd = html.indexOf("</head>");
|
|
114
|
+
const positions = [html.indexOf(gtag), html.indexOf(inline), html.indexOf(og), html.indexOf('rel="icon"')];
|
|
115
|
+
for (const p of positions) expect(p).toBeGreaterThan(-1);
|
|
116
|
+
for (const p of positions) expect(p).toBeLessThan(headEnd);
|
|
117
|
+
expect([...positions].sort((a, b) => a - b)).toEqual(positions);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("本地 href 物化为 assets/<sha256><ext> 并回填标签;外链不进 assets/;写盘与 server 读取同源", async () => {
|
|
121
|
+
const root = await seedRoot();
|
|
122
|
+
const path = await seedReport(root, HEAD_DECL);
|
|
123
|
+
const plan = await planSite(root, { report: { path, cwd: root } });
|
|
124
|
+
|
|
125
|
+
const sha256 = createHash("sha256").update(FAVICON_SVG).digest("hex");
|
|
126
|
+
const assetPath = `assets/${sha256}.svg`;
|
|
127
|
+
const html = (await readSiteFile(plan.files.get("index.html")!)) as string;
|
|
128
|
+
expect(html).toContain(`<link rel="icon" href="${assetPath}">`);
|
|
129
|
+
|
|
130
|
+
const assetKeys = [...plan.files.keys()].filter((p) => p.startsWith("assets/"));
|
|
131
|
+
expect(assetKeys).toEqual([assetPath]); // 外链(gtag)不 vendored
|
|
132
|
+
expect(plan.files.get(assetPath)!.contentType).toBe("image/svg+xml");
|
|
133
|
+
// server 面:原字节读取(二进制安全)。
|
|
134
|
+
const served = await readSiteFile(plan.files.get(assetPath)!);
|
|
135
|
+
expect(Buffer.isBuffer(served)).toBe(true);
|
|
136
|
+
expect((served as Buffer).toString("utf-8")).toBe(FAVICON_SVG);
|
|
137
|
+
// 导出面:writeSite 落盘同一份字节。
|
|
138
|
+
const out = join(root, "out");
|
|
139
|
+
await writeSite(plan, out);
|
|
140
|
+
expect(await readFile(join(out, assetPath), "utf-8")).toBe(FAVICON_SVG);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("head 注入不改初始 HTML 的报告数据节点;head 不进 viewData.report", async () => {
|
|
144
|
+
const root = await seedRoot();
|
|
145
|
+
const withHead = await seedReport(root, HEAD_DECL);
|
|
146
|
+
const planWith = await planSite(root, { report: { path: withHead, cwd: root } });
|
|
147
|
+
|
|
148
|
+
const bare = join(root, "bare-report.mjs");
|
|
149
|
+
await writeFile(bare, headReportSource("[]"), "utf-8");
|
|
150
|
+
const planWithout = await planSite(root, { report: { path: bare, cwd: root } });
|
|
151
|
+
|
|
152
|
+
const extractTemplate = (html: string): string => {
|
|
153
|
+
const match = html.match(/<template id="niceeval-report-report-en">[\s\S]*?<\/template>/);
|
|
154
|
+
expect(match).not.toBeNull();
|
|
155
|
+
return match![0];
|
|
156
|
+
};
|
|
157
|
+
const htmlWith = (await readSiteFile(planWith.files.get("index.html")!)) as string;
|
|
158
|
+
const htmlWithout = (await readSiteFile(planWithout.files.get("index.html")!)) as string;
|
|
159
|
+
expect(extractTemplate(htmlWith)).toBe(extractTemplate(htmlWithout));
|
|
160
|
+
|
|
161
|
+
// head 是注入资产,不进序列化声明(viewData.report)。
|
|
162
|
+
const scan = await loadViewScan(root, { report: { path: withHead, cwd: root } });
|
|
163
|
+
expect(scan.viewData.report).not.toHaveProperty("head");
|
|
164
|
+
expect(scan.shellAssets.head).toHaveLength(4);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("本地 href 缺失文件:装载报错并给出解析后的绝对路径", async () => {
|
|
168
|
+
const root = await seedRoot();
|
|
169
|
+
const path = join(root, "missing-asset.mjs");
|
|
170
|
+
await writeFile(
|
|
171
|
+
path,
|
|
172
|
+
headReportSource(JSON.stringify([{ tag: "link", attrs: { rel: "icon", href: "./nope.svg" } }])),
|
|
173
|
+
"utf-8",
|
|
174
|
+
);
|
|
175
|
+
await expect(planSite(root, { report: { path, cwd: root } })).rejects.toThrow(/asset not found: .*nope\.svg/);
|
|
176
|
+
});
|
|
177
|
+
});
|