mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:31:58 +00:00
可验证报告复核修正(第 1 轮):运行信息拿已核对的事件对照,报告徽标连同数字一起算,遮罩判定宁严勿宽,各分支在浏览器里有确定的检查
- 页面核对加 ⑥ 运行信息:运行结果看封存范围内最后一条结束类事件的类型,运行类别看运行开始事件记下的运行参数, 出具档位认最后一条出具判定(不再信元信息指的那一条);页面显示事件里的值,元信息写得不一样算不通过、计入总览。 工作流名、版本、起止时间、节点名称绑不进封存,单列一组写明取自运行记录,封存说明里也写明要到系统里一并对照 - 报告标题旁的徽标连同正文里的数字一起算,旁边写分项:重算全部哈希的伪造只在 ⑤ 露馅,徽标不能还写着通过。 分块条目多时不通过和无法核对的都列在外面,有无法核对的显示「部分无法核对」和分项计数;⑤ 改叫「数字与取值」并分开数, 读屏分别读作数字、取值,「显示格式由服务端渲染」只附在带出处的数字上;无出处数字的说法改成未使用引用标记 - 服务端不会给出的状态算不通过:被引用的工件不在清单里、非查询快照标着遮罩、工件既没内容也没说明;报告文档导出时 已不在服务端,总览不再是绿色的通过,写「有 N 份报告未能核对」,报告处写明是服务端已没有这份文档 - 认 json.dumps 写出的裸 NaN / Infinity,没被改过的文件不再误判不通过;哈希对得上但读不懂的照实写「无法核对」, 和哈希不一致分开说 - 宽屏打开出处面板时正文让出右边一栏,表格里右对齐的数字不再被面板盖住;补上斜体(不用后行断言,较早的 Safari 也能执行) - 封存哈希、运行 ID、工作流名直接写进页面,不执行脚本时写「尚未核对」而不是一直「正在核对」;脚本挪进 head, 执行后核一遍页面顶部写出的封存哈希和数据里核对用的一致 - 成果节点和运行结束事件的规范字符串,只在封存范围内每次数据源查询都对得上无遮罩的快照(或确实没交回数据)时才给: 升级前的运行从工具快照认出查询快照,协作团队成员的查询认不出就按带遮罩算,数据源现在设了遮罩的按工具名就挡 - 封存核对不一致的 409 改成一句固定的话,不再叠冒号、让人去本来就在的记录页;失败结束的运行在页面封存说明和导出提示里 写明之后继续运行会重新封存;记录页上左边写着「已封存」却不能导出时,原因写在屏幕上 - tests/bundle_samples.py 用虚构数据真实生成九份样本页(遮罩快照、两份报告、早期运行、失败结束、两条出具判定、 服务端缺文件、裸 NaN、别的运行的文档),check-bundle 断网打开逐项断言,在上面篡改引用链、运行信息、拿掉被引用的口径卡, 量宽屏面板,看不执行脚本的样子;把核对改成恒通过、徽标不看数字等七种改坏法都会变红。pytest 补遮罩判定的三种形状、 事后设遮罩(去掉接线即变红)、页面静态内容和样本页本身
This commit is contained in:
@@ -34,7 +34,8 @@ BUNDLE_SEAL_BROKEN = "bundle_seal_broken"
|
||||
#: 运行结束的状态:封存是最后一次的,之后不会再换(除非失败的运行又被接着跑,那是另一次封存)
|
||||
_FINISHED = frozenset({"succeeded", "failed", "cancelled"})
|
||||
UNSEALED = "运行尚未结束封存,结束后才能导出"
|
||||
SEAL_BROKEN = "封存核对不一致:{why}。不能导出可验证报告,请先在记录页核对这次运行"
|
||||
#: 原样出现在记录页的错误提示里,导出按钮旁边就是「核对」。不拼封存核对的结论原句:那句自带冒号和「不一致」
|
||||
SEAL_BROKEN = "封存核对不一致:封存后有事件被修改、删除或插入,不能导出可验证报告。点「核对」可查看核对结果"
|
||||
|
||||
|
||||
@router.get("/{run_id}/bundle.html")
|
||||
@@ -44,8 +45,7 @@ async def export_bundle(run_id: str) -> Response:
|
||||
if not sealed.sealed or sealed.status not in _FINISHED:
|
||||
raise CodedHTTPException(409, UNSEALED, BUNDLE_UNSEALED)
|
||||
if not sealed.trusted:
|
||||
why = str(sealed.verdict.get("message") or "封存后有事件被修改、删除或插入").rstrip("。")
|
||||
raise CodedHTTPException(409, SEAL_BROKEN.format(why=why), BUNDLE_SEAL_BROKEN)
|
||||
raise CodedHTTPException(409, SEAL_BROKEN, BUNDLE_SEAL_BROKEN)
|
||||
bound = sealed.seal["manifest_seq"]
|
||||
async with SessionLocal() as session:
|
||||
run = await session.get(Run, run_id)
|
||||
|
||||
@@ -32,9 +32,10 @@ from urllib.parse import quote
|
||||
|
||||
from app.core.artifact_store import canonical_json, content_hash
|
||||
from app.core.events import EventType
|
||||
from app.engine.evidence import iter_units
|
||||
from app.engine.evidence import iter_units, query_entry_fields
|
||||
from app.engine.merge_query import MERGE_SOURCE
|
||||
from app.engine.schema import NodeType
|
||||
from app.engine.toolcalls import QUERY_PREFIX
|
||||
|
||||
SCHEMA = "agentlab.bundle/1"
|
||||
|
||||
@@ -130,6 +131,71 @@ def _query_artifacts(events: Iterable[tuple[int, str, str | None, Any]]) -> list
|
||||
return list(dict.fromkeys(a for a in out if a))
|
||||
|
||||
|
||||
_UNSEEN = object()
|
||||
|
||||
|
||||
def _query_returned(data: Mapping[str, Any], store: _Store) -> tuple[str, str | None]:
|
||||
"""一次数据源查询(db_query__ 工具的 tool.end)交回了什么:("snapshot", 查询快照 id);("none", None) 没交回数据
|
||||
(查询失败、SQL 被拒的那句原话);认不出来是 ("unknown", None)。
|
||||
|
||||
新运行在 query_artifact 里直接记了快照 id。没记的有三种:查询没成功;升级前的运行(证据台账之前,tool.end 只有
|
||||
工具快照,查询快照的 id 嵌在工具快照记下的返回里,照证据接口 _legacy_pool 的办法认);协作团队成员的查询(连工具
|
||||
快照都不落,只能看事件里的预览,长一点的返回被截断,解析不了)。"""
|
||||
artifact = data.get("query_artifact")
|
||||
if isinstance(artifact, str) and artifact:
|
||||
return "snapshot", artifact
|
||||
returned: Any = _UNSEEN
|
||||
snapshot = data.get("artifact")
|
||||
# 工具快照要哈希对得上、记的是同一个工具:被改过的不能拿来说这次查询没有遮罩
|
||||
if isinstance(snapshot, str) and snapshot and store.intact(snapshot):
|
||||
content = store.content(snapshot)
|
||||
if isinstance(content, dict) and content.get("tool") == data.get("tool") and "result" in content:
|
||||
returned = content["result"]
|
||||
if returned is _UNSEEN:
|
||||
preview = data.get("preview")
|
||||
if not isinstance(preview, str):
|
||||
return "unknown", None
|
||||
returned = preview
|
||||
if preview.startswith('"'):
|
||||
# 调用工具节点的预览是把返回值再 json.dumps 一遍(文本外面套了一层引号),截断了就剥不开
|
||||
try:
|
||||
returned = json.loads(preview)
|
||||
except ValueError:
|
||||
return "unknown", None
|
||||
fields = query_entry_fields(returned)
|
||||
if fields:
|
||||
return "snapshot", str(fields["artifact"])
|
||||
# 数据源工具成功时交回的是 JSON 对象;不是对象的是一句原话,对象里没有 rows 的是报错
|
||||
if isinstance(returned, str) and not returned.lstrip().startswith("{"):
|
||||
return "none", None
|
||||
if isinstance(returned, dict) and "rows" not in returned:
|
||||
return "none", None
|
||||
return "unknown", None
|
||||
|
||||
|
||||
def _masked_run(events: Sequence[tuple[int, str, str | None, Any]], store: _Store, cited: Iterable[str],
|
||||
source_masks: Mapping[str, Any]) -> bool:
|
||||
"""运行里有没有哪次查询的结果可能带着遮罩列。有的话,成果节点和运行结束事件的预览可能直接带出查询结果,只给哈希。
|
||||
|
||||
宁严勿宽:封存范围内每一次数据源查询都要对得上一份没有遮罩的查询快照,或者确实没交回数据,才算没有。数据源
|
||||
现在设了遮罩的,按工具名就知道,不必去认快照;认不出查了什么的,按带遮罩算。"""
|
||||
known = [*_query_artifacts(events), *cited]
|
||||
for _, etype, _, data in events:
|
||||
if etype != EventType.TOOL_END or not isinstance(data, dict):
|
||||
continue
|
||||
tool = str(data.get("tool") or "")
|
||||
if not tool.startswith(QUERY_PREFIX):
|
||||
continue
|
||||
if source_masks.get(tool[len(QUERY_PREFIX):]):
|
||||
return True
|
||||
kind, artifact = _query_returned(data, store)
|
||||
if kind == "unknown":
|
||||
return True
|
||||
if artifact:
|
||||
known.append(artifact)
|
||||
return any(store.masked(a) for a in dict.fromkeys(known))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 报告文档和它引用到的工件
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -330,8 +396,7 @@ def build_bundle(
|
||||
if kind == "query":
|
||||
cited_queries.append(artifact)
|
||||
|
||||
# 运行里有没有带遮罩的查询:有的话,成果节点和运行结束事件的预览可能直接带出查询结果,只给哈希
|
||||
masked_run = any(store.masked(a) for a in dict.fromkeys([*_query_artifacts(sealed), *cited_queries]))
|
||||
masked_run = _masked_run(sealed, store, cited_queries, source_masks or {})
|
||||
shown_events = []
|
||||
for seq, etype, node_id, data in sealed:
|
||||
text = canonical_json(data)
|
||||
@@ -367,7 +432,7 @@ def build_bundle(
|
||||
#: U+2028 / U+2029 在老的 JS 解析器里是换行,一并转义。这几个字只会出现在 JSON 的字符串里,转义后 JSON 不变
|
||||
_JSON_ESCAPES = str.maketrans({"<": "\\u003c", ">": "\\u003e", "&": "\\u0026",
|
||||
"\u2028": "\\u2028", "\u2029": "\\u2029"})
|
||||
_PLACEHOLDER = re.compile(r"__BUNDLE_(TITLE|CSP|DATA|SCRIPT)__")
|
||||
_PLACEHOLDER = re.compile(r"__BUNDLE_(TITLE|NAME|SEAL|RUN|FAILED|CSP|DATA|SCRIPT)__")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -385,10 +450,16 @@ def render_html(bundle: Mapping[str, Any]) -> str:
|
||||
"""把数据和核对脚本填进模板:样式、脚本都内联,零外链。
|
||||
|
||||
内容安全策略按哈希只放行这一段核对脚本,默认什么都不许连:页面打开后不会、也不能向任何地方发请求。
|
||||
工作流名、封存哈希、运行 ID 直接写进页面:邮件、即时通讯的附件预览和系统的快速查看常常不执行脚本,也要看得到
|
||||
该拿什么去系统里对照。脚本执行后拿数据里的封存哈希核对,再核一遍这里写出的和它一致。失败结束的运行另写一句:
|
||||
之后在系统中继续运行会重新封存,这个文件就再也对不上了。
|
||||
占位符一次替换完,填进去的内容不会再被当成占位符。"""
|
||||
template, script = _assets()
|
||||
digest = base64.b64encode(hashlib.sha256(script.encode("utf-8")).digest()).decode("ascii")
|
||||
title = f"{(bundle.get('meta') or {}).get('workflow_name') or UNSAVED_NAME} · 可验证报告"
|
||||
values = {"TITLE": html.escape(title), "CSP": f"'sha256-{digest}'", "DATA": bundle_json(bundle),
|
||||
"SCRIPT": script}
|
||||
meta = bundle.get("meta") or {}
|
||||
name = str(meta.get("workflow_name") or UNSAVED_NAME)
|
||||
values = {"TITLE": html.escape(f"{name} · 可验证报告"), "NAME": html.escape(name),
|
||||
"SEAL": html.escape(str(meta.get("manifest_hash") or "")), "RUN": html.escape(str(meta.get("run_id") or "")),
|
||||
"FAILED": "" if meta.get("status") == "failed" else " hidden",
|
||||
"CSP": f"'sha256-{digest}'", "DATA": bundle_json(bundle), "SCRIPT": script}
|
||||
return _PLACEHOLDER.sub(lambda m: values[m.group(1)], template)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
--mark: #fff1b8; --code: #eff1f5; --shadow: 0 10px 32px rgba(16, 20, 28, .16);
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--detail-w: 420px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
@@ -24,6 +25,8 @@
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
/* 核对脚本一开始执行就给 html 标上 data-js:没执行(禁用脚本、附件预览)时只显示「尚未核对」那一句 */
|
||||
html:not([data-js]) .js-only, html[data-js] .no-js { display: none !important; }
|
||||
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
||||
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.65 var(--sans); overflow-wrap: break-word; }
|
||||
code, pre, .mono { font-family: var(--mono); }
|
||||
@@ -34,25 +37,31 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
|
||||
border: 1px solid var(--border); background: var(--panel); font-size: 18px; font-weight: 650; }
|
||||
.overall[data-overall="pass"] { color: var(--ok); background: var(--ok-soft); border-color: transparent; }
|
||||
.overall[data-overall="fail"] { color: var(--bad); background: var(--bad-soft); border-color: transparent; }
|
||||
.overall[data-overall="incomplete"] { color: var(--na); background: var(--na-soft); border-color: var(--na); }
|
||||
.overall-mark { font-size: 20px; line-height: 1; }
|
||||
.overall-sub { margin: 4px 2px 0; font-size: 13px; color: var(--dim); }
|
||||
.seal { margin: 18px 0; padding: 14px 16px; border: 2px solid var(--accent); border-radius: 10px; }
|
||||
.seal h2 { margin: 0 0 6px; font-size: 13px; font-weight: 600; color: var(--dim); }
|
||||
.hash { display: block; font-size: 15px; font-weight: 600; line-height: 1.5; word-break: break-all; color: var(--fg); }
|
||||
.seal p { margin: 10px 0 0; font-size: 13px; color: var(--dim); }
|
||||
.seal p code { word-break: break-all; }
|
||||
.seal .seal-warn { padding: 8px 10px; border-radius: 8px; background: var(--bad-soft); color: var(--bad); }
|
||||
.meta { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 0; font-size: 13px; }
|
||||
.meta dt { color: var(--faint); }
|
||||
.meta dd { margin: 0; min-width: 0; color: var(--dim); overflow-wrap: anywhere; }
|
||||
.meta-note { margin: 14px 0 6px; font-size: 12px; color: var(--faint); }
|
||||
.block { margin-top: 36px; }
|
||||
.block > h2 { margin: 0 0 10px; font-size: 18px; }
|
||||
.muted { color: var(--faint); }
|
||||
.bad { color: var(--bad); }
|
||||
.note { margin: 6px 0 12px; font-size: 13px; color: var(--dim); }
|
||||
.warn { margin: 0 0 10px; padding: 8px 10px; border-radius: 8px; background: var(--bad-soft); color: var(--bad); font-size: 13px; }
|
||||
.warn[data-why="missing"], .warn[data-why="unreadable"] { background: var(--na-soft); color: var(--na); }
|
||||
.report { margin: 14px 0; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--raise); }
|
||||
.report-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-bottom: 10px; }
|
||||
.report-head h3 { margin: 0; font-size: 15px; }
|
||||
.report-head .mono { font-size: 12px; }
|
||||
.report-parts { flex: 1 1 100%; font-size: 12px; color: var(--dim); }
|
||||
.doc { font-size: 15px; }
|
||||
.doc p { margin: 8px 0; }
|
||||
.doc .doc-h { margin: 18px 0 6px; line-height: 1.4; }
|
||||
@@ -84,7 +93,7 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
|
||||
font-weight: 600; line-height: 1.5; white-space: nowrap; }
|
||||
.badge[data-result="pass"] { color: var(--ok); background: var(--ok-soft); }
|
||||
.badge[data-result="fail"] { color: var(--bad); background: var(--bad-soft); }
|
||||
.badge[data-result="na"] { color: var(--na); background: var(--na-soft); }
|
||||
.badge[data-result="na"], .badge[data-result="partial"] { color: var(--na); background: var(--na-soft); }
|
||||
.check { margin: 12px 0; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.check-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 10px 12px; background: var(--panel); }
|
||||
.check-head h3 { margin: 0; font-size: 14px; }
|
||||
@@ -95,7 +104,7 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
|
||||
.items .why { flex: 1 1 100%; color: var(--dim); }
|
||||
.items button { font: inherit; color: var(--accent); background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }
|
||||
details > summary { padding: 8px 12px; border-top: 1px solid var(--border); font-size: 13px; color: var(--accent); cursor: pointer; }
|
||||
.detail { position: fixed; top: 0; right: 0; bottom: 0; z-index: 10; width: min(420px, 100vw); overflow-y: auto;
|
||||
.detail { position: fixed; top: 0; right: 0; bottom: 0; z-index: 10; width: min(var(--detail-w), 100vw); overflow-y: auto;
|
||||
background: var(--raise); border-left: 1px solid var(--border); box-shadow: var(--shadow); }
|
||||
.detail-head { position: sticky; top: 0; display: flex; align-items: flex-start; gap: 8px; padding: 14px 16px 10px;
|
||||
background: var(--raise); border-bottom: 1px solid var(--border); }
|
||||
@@ -111,12 +120,15 @@ details > summary { padding: 8px 12px; border-top: 1px solid var(--border); font
|
||||
.sub { margin: 14px 0 4px; font-size: 13px; color: var(--faint); font-weight: 600; }
|
||||
.foot { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--faint); }
|
||||
.foot p { margin: 4px 0; }
|
||||
.noscript { max-width: 920px; margin: 24px auto; padding: 0 16px; color: var(--bad); }
|
||||
@media print {
|
||||
.detail, .close, details > summary { display: none !important; }
|
||||
.page { max-width: none; padding: 0; }
|
||||
.page, html.detail-open .page { max-width: none; margin: 0; padding: 0; }
|
||||
.num { text-decoration-color: currentColor !important; background: none !important; }
|
||||
}
|
||||
/* 宽屏上出处面板停在右侧:打开时正文让出这一栏,表格里右对齐的数字不会被面板盖住 */
|
||||
@media screen and (min-width: 761px) {
|
||||
html.detail-open .page { margin-right: min(var(--detail-w), 100vw); }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.detail { top: auto; left: 0; width: auto; max-height: 72vh; border-left: 0; border-top: 1px solid var(--border); border-radius: 14px 14px 0 0; }
|
||||
}
|
||||
@@ -131,36 +143,38 @@ details > summary { padding: 8px 12px; border-top: 1px solid var(--border); font
|
||||
.report { padding: 12px; }
|
||||
}
|
||||
</style>
|
||||
<script>__BUNDLE_SCRIPT__</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header>
|
||||
<p class="brand">可验证报告</p>
|
||||
<h1 id="title">可验证报告</h1>
|
||||
<h1 id="title">__BUNDLE_NAME__</h1>
|
||||
<div id="overall" class="overall" data-overall="running" role="status" aria-live="polite">
|
||||
<span class="overall-mark" aria-hidden="true">…</span><span id="overall-text">正在核对…</span>
|
||||
<span class="overall-mark js-only" aria-hidden="true">…</span><span id="overall-text"><span class="js-only">正在核对…</span><span class="no-js">尚未核对:需要在浏览器中打开本文件,并允许执行其中的核对脚本</span></span>
|
||||
</div>
|
||||
<p id="overall-sub" class="overall-sub"></p>
|
||||
<section class="seal" aria-labelledby="seal-label">
|
||||
<h2 id="seal-label">封存清单哈希</h2>
|
||||
<code id="seal-hash" class="hash"></code>
|
||||
<p id="seal-note"></p>
|
||||
<code id="seal-hash" class="hash">__BUNDLE_SEAL__</code>
|
||||
<p id="seal-note">这个文件只证明内部一致:报告里的数字能追到口径卡或查询快照,文档、快照和运行记录彼此对得上。它不能单独证明这些记录出自系统、未被整体替换。核对真伪:在系统中打开这次运行(运行 ID <code class="run-id">__BUNDLE_RUN__</code>),确认「凭证详情」里的清单哈希与上面一致,并点「核对」确认事件记录与封存时一致(接口 GET /api/runs/<span class="run-id">__BUNDLE_RUN__</span>/verify)。工作流名、版本、起止时间和节点名称取自运行记录,不在封存范围内,需要在系统中一并对照。</p>
|
||||
<p id="seal-failed" class="seal-warn"__BUNDLE_FAILED__>这次运行以失败结束:之后若在系统中继续运行,系统会重新封存,本文件的封存哈希将无法再与系统对照。</p>
|
||||
</section>
|
||||
<dl id="meta" class="meta"></dl>
|
||||
<p id="record-note" class="meta-note" hidden></p>
|
||||
<dl id="record" class="meta"></dl>
|
||||
</header>
|
||||
<main>
|
||||
<main class="js-only">
|
||||
<section id="reports" class="block" aria-labelledby="reports-title"><h2 id="reports-title">报告</h2></section>
|
||||
<section id="checks" class="block" aria-labelledby="checks-title"><h2 id="checks-title">核对明细</h2></section>
|
||||
<section id="appended" class="block" aria-labelledby="appended-title" hidden><h2 id="appended-title">封存之后追加的事件</h2></section>
|
||||
</main>
|
||||
<footer id="foot" class="foot"></footer>
|
||||
<footer id="foot" class="foot js-only"></footer>
|
||||
</div>
|
||||
<aside id="detail" class="detail" role="dialog" aria-modal="false" aria-labelledby="detail-title" hidden>
|
||||
<div class="detail-head"><h2 id="detail-title"></h2><button type="button" id="detail-close" class="close" aria-label="关闭">×</button></div>
|
||||
<div id="detail-body" class="detail-body"></div>
|
||||
</aside>
|
||||
<noscript><p class="noscript">这个文件在浏览器里自行核对,需要启用 JavaScript。</p></noscript>
|
||||
<script type="application/json" id="bundle-data">__BUNDLE_DATA__</script>
|
||||
<script>__BUNDLE_SCRIPT__</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+361
-107
@@ -1,20 +1,24 @@
|
||||
/*
|
||||
* 可验证报告的浏览器端核对:原生 JS,无依赖,不连网。
|
||||
*
|
||||
* 数据在 <script type="application/json" id="bundle-data"> 里(服务端 bundle/build.py 生成),逐项核对:
|
||||
* 数据在页面末尾 id 为 bundle-data 的 JSON 数据块里(服务端 bundle/build.py 生成),逐项核对:
|
||||
* ① 每条带规范字符串的事件:sha256(canonical) 等于它的 data_sha256;
|
||||
* ② 封存清单:照 runner 的拼法(每行 seq|type|node_id|data_sha256,node_id 为空写空串,按 seq 排序,换行连接,
|
||||
* 没有结尾换行)重算,等于封存哈希;
|
||||
* 没有结尾换行)重算,等于封存哈希;页面顶部直接写出的那份封存哈希(不执行脚本也看得到)要和它一致;
|
||||
* ③ 每份嵌入的工件:sha256(原字节) 等于工件 id;
|
||||
* ④ 引用链:报告文档的 id 是这个报告节点最后一次自查事件(已核对)记下的那份,文档记的运行、节点对得上;
|
||||
* 口径卡和快照的 id 出现在已核对的事件内容里,或者已核对的报告文档的证据目录里;
|
||||
* ⑤ 报告里的每个数字:引用的值等于口径卡里那个指标的值,或者快照里那一格的值。判等照搬服务端
|
||||
* engine/expressions.py 的 cell_value / same_value;数字怎么显示是服务端渲染好的,这里不重新排版。
|
||||
* 每项给出通过 / 不通过 / 无法核对和原因。这些只证明内部一致,真伪要拿封存哈希去系统里对照,页面上写明。
|
||||
* ⑤ 报告里的每个数字和取值:引用的值等于口径卡里那个指标的值,或者快照里那一格的值。判等照搬服务端
|
||||
* engine/expressions.py 的 cell_value / same_value;数字怎么显示是服务端渲染好的,这里不重新排版;
|
||||
* ⑥ 运行信息:运行结果、运行类别、出具档位拿封存范围内已核对的事件对照。工作流名、版本、起止时间、节点名称
|
||||
* 绑不进封存,页面上写明它们取自运行记录。
|
||||
* 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、口径卡标着遮罩)
|
||||
* 一律算不通过:只可能是文件被改过。这些只证明内部一致,真伪要拿封存哈希去系统里对照,页面上写明。
|
||||
*
|
||||
* SHA-256 优先用浏览器的 crypto.subtle;拿不到(非安全上下文、某些浏览器打开本地文件时)用下面的纯 JS 实现,
|
||||
* 两者结果一致。纯 JS 实现启动时先拿标准测试向量自检,自检不过就不下任何结论。
|
||||
* 来自数据的文字一律经 textContent 写进页面,不拼 HTML。
|
||||
* 脚本放在 head 里:一开始执行就给 html 标上 data-js(样式表据此把「尚未核对」换成「正在核对」,附件预览这类不执行
|
||||
* 脚本的地方看到的是前一句),等文档解析完再核对。来自数据的文字一律经 textContent 写进页面,不拼 HTML。
|
||||
*/
|
||||
(() => {
|
||||
'use strict'
|
||||
@@ -22,15 +26,21 @@
|
||||
const PASS = 'pass'
|
||||
const FAIL = 'fail'
|
||||
const NA = 'na'
|
||||
/** 汇总用(分块、报告的徽标):一组里有通过也有无法核对,没有不通过 */
|
||||
const PARTIAL = 'partial'
|
||||
/** 一块里条目超过这么多就收起来,只把不通过和无法核对的列在外面 */
|
||||
const FOLD_AT = 20
|
||||
|
||||
// ------------------------------------------------------------------ 文案
|
||||
|
||||
const RESULT = { pass: '通过', fail: '不通过', na: '无法核对' }
|
||||
const MARK = { pass: '✓', fail: '✗', na: '?' }
|
||||
const RESULT = { pass: '通过', fail: '不通过', na: '无法核对', partial: '部分无法核对' }
|
||||
const MARK = { pass: '✓', fail: '✗', na: '?', partial: '?' }
|
||||
const KIND_LABEL = { report_doc: '报告文档', metric_set: '口径卡', query: '查询快照' }
|
||||
const TIER_LABEL = { formal: '完整出具', degraded: '降档出具', withheld: '不予出具' }
|
||||
const CLASS_LABEL = { formal: '正式运行', exploratory: '探索运行' }
|
||||
const STATUS_LABEL = { succeeded: '已完成', failed: '已失败', cancelled: '已取消' }
|
||||
/** 结束类事件 → 它说明的运行结果(runner:成功、失败、取消各发一条,落库之后才封存) */
|
||||
const TERMINAL = { 'run.finished': 'succeeded', 'run.failed': 'failed', 'run.cancelled': 'cancelled' }
|
||||
const EVENT_LABEL = {
|
||||
'run.started': '运行开始', 'run.finished': '运行结束', 'run.failed': '运行失败', 'run.cancelled': '运行取消',
|
||||
'run.interrupted': '等待人工介入', 'run.resumed': '恢复运行', 'node.started': '节点开始', 'node.finished': '节点完成',
|
||||
@@ -47,47 +57,58 @@
|
||||
allPass: '全部通过',
|
||||
passWithNa: '可核对的项全部通过',
|
||||
failed: (n) => `有 ${n} 项不通过`,
|
||||
incomplete: (n) => `有 ${n} 份报告未能核对`,
|
||||
tally: (p, f, n) => `通过 ${p} 项 · 不通过 ${f} 项 · 无法核对 ${n} 项`,
|
||||
broken: '页面数据无法解析,无法核对',
|
||||
selfTestFailed: '本页的 SHA-256 实现在这个浏览器中自检未通过,不给出核对结论',
|
||||
sealNote: (id) => '这个文件只证明内部一致:报告里的数字能追到口径卡或查询快照,文档、快照和运行记录彼此对得上。'
|
||||
+ '它不能单独证明这些记录出自系统、未被整体替换。核对真伪:在系统中打开这次运行(运行 ID '
|
||||
+ `${id}),确认「凭证详情」里的清单哈希与上面一致,并点「核对」确认事件记录与封存时一致`
|
||||
+ `(接口 GET /api/runs/${id}/verify)。`,
|
||||
metaRun: '运行 ID', metaWorkflow: '工作流', metaClass: '运行类别', metaStatus: '运行结果', metaVersionHash: '版本哈希',
|
||||
metaStarted: '开始',
|
||||
metaFinished: '结束', metaRange: '封存范围', metaTier: '出具档位', metaExported: '导出时间',
|
||||
metaRun: '运行 ID', metaStatus: '运行结果', metaClass: '运行类别', metaTier: '出具档位', metaRange: '封存范围',
|
||||
metaExported: '导出时间', metaWorkflow: '工作流', metaVersion: '版本', metaVersionHash: '版本哈希', metaStarted: '开始',
|
||||
metaFinished: '结束',
|
||||
recordNote: '以下几项和页面上的节点名称取自运行记录,不在封存范围内,需要在系统中一并对照',
|
||||
// 序号不连续(模型的流式输出占序号、不落库),后面注明范围内实际有几条。说法同记录页的封存凭证
|
||||
range: (seq, count) => `第 1–${seq} 条事件(范围内 ${count} 条)`,
|
||||
rangeLegacy: '(早期运行,范围按结束事件推算)',
|
||||
tierChecked: (seq) => `第 ${seq} 条事件,已核对`,
|
||||
tierUnchecked: '与已核对的事件对不上',
|
||||
markPass: '已核对',
|
||||
markFail: (claim) => `文件里写的是「${claim}」,见核对明细 ⑥`,
|
||||
markNa: '无法核对',
|
||||
noReports: '这次运行没有报告文档,下方只核对运行记录。',
|
||||
reportTitle: (label) => `报告「${label}」`,
|
||||
reportParts: (doc, nums) => `文档 ${doc} · 数字与取值 ${nums}`,
|
||||
reportNoNumbers: '无',
|
||||
docUntrusted: '这份报告文档没有通过核对(见核对明细 ③ ④),正文仅供参考,其中的数字不予核对。',
|
||||
docUnreadable: '报告文档无法解析,无法显示正文。',
|
||||
docUnreadable: '这份报告文档的哈希一致,但内容无法解析,无法显示正文。',
|
||||
docMissing: '导出时服务端已没有这份报告文档,无法显示正文,其中的数字也无法核对。',
|
||||
docAbsent: '文件里没有附带这份报告文档。',
|
||||
checkEvents: '① 事件内容与哈希',
|
||||
checkManifest: '② 封存清单',
|
||||
checkArtifacts: '③ 工件与哈希',
|
||||
checkChain: '④ 引用链',
|
||||
checkNumbers: '⑤ 报告中的数字',
|
||||
checkNumbers: '⑤ 报告中的数字与取值',
|
||||
checkMeta: '⑥ 运行信息',
|
||||
sumEvents: (n) => `${n} 条带内容的事件;其余事件只附哈希,参与 ② 的清单核对`,
|
||||
sumManifest: (n) => `按 ${n} 条事件的哈希重算`,
|
||||
sumCount: (n, unit) => `${n} ${unit}`,
|
||||
sumNumbers: (numbers, values) => `数字 ${numbers} 个 · 取值 ${values} 个`,
|
||||
sumMeta: '与封存范围内已核对的事件对照',
|
||||
showAll: (n) => `展开全部 ${n} 项`,
|
||||
eventMismatch: '内容与记录的哈希不一致',
|
||||
eventNotJson: '内容不是有效的 JSON',
|
||||
manifestComputed: '重算结果',
|
||||
manifestShown: '页面顶部写出的封存哈希',
|
||||
manifestLines: (n) => `查看参与清单核对的 ${n} 行(每行 序号|类型|节点|内容哈希,可复制后用任意 SHA-256 工具自行计算)`,
|
||||
manifestOk: '与封存清单哈希一致',
|
||||
manifestMismatch: '与封存清单哈希不一致:事件被增删、改动或换了顺序',
|
||||
beyondSeal: (seq) => `第 ${seq} 条事件的序号超出封存范围`,
|
||||
rangeMismatch: (seq, last) => `封存范围写的是第 1–${seq} 条,范围内最后一条事件却是第 ${last} 条`,
|
||||
shownOk: '与核对用的封存哈希一致',
|
||||
shownMismatch: '与数据里核对用的封存哈希不一致,页面被改过;上方现在显示的是核对用的那一份',
|
||||
artifactMismatch: '内容与工件 id(哈希)不一致',
|
||||
artifactUndecodable: '内容无法解码',
|
||||
artifactNotJson: '内容不是有效的 JSON',
|
||||
artifactUnparsable: '哈希一致,但内容无法解析',
|
||||
masked: '含遮罩列,未随文件附带,只能核对到哈希',
|
||||
maskedOdd: '只有查询快照会因遮罩不随文件附带,这份工件却标着遮罩:导出时不会出现这种情况,文件可能被改过',
|
||||
artifactMissing: '导出时服务端已没有这份工件的文件',
|
||||
artifactAbsent: '内容未随文件附带',
|
||||
artifactAbsent: '内容未随文件附带:导出时不会出现这种情况,文件可能被改过',
|
||||
chainDoc: (label) => `报告「${label}」的文档`,
|
||||
chainByEvent: (seq, what) => `第 ${seq} 条事件(${what})`,
|
||||
chainByDoc: (label, alias) => `报告「${label}」的证据目录(${alias})`,
|
||||
@@ -95,7 +116,10 @@
|
||||
chainNoCheckEvent: '找不到这个报告节点的报告自查事件',
|
||||
chainCheckEventFailed: '报告自查事件的内容没有通过核对',
|
||||
chainOtherDoc: '这个节点最后一次报告自查记下的不是这份文档',
|
||||
chainDocNotListed: '文件的工件清单里没有这份报告文档:导出时不会出现这种情况,文件可能被改过',
|
||||
chainDocUnverified: '文档内容没有通过核对,无法确认它记下的运行和节点',
|
||||
chainDocMissing: '导出时服务端已没有这份报告文档,无法确认它记下的运行和节点',
|
||||
chainDocUnparsable: '文档的哈希一致,但内容无法解析,无法确认它记下的运行和节点',
|
||||
chainDocElsewhere: '文档记下的运行或节点与这次不符',
|
||||
chainDocLeftOut: '运行记录里有这个报告节点的自查事件,文件里却没有附带它的文档',
|
||||
chainUnreferenced: '已核对的事件和报告文档里都没有引用这份工件',
|
||||
@@ -105,20 +129,37 @@
|
||||
srcInput: (field) => `运行输入「${field}」`,
|
||||
srcNone: '没有出处',
|
||||
srcOther: '其他引用',
|
||||
numUncited: '这个数字没有引用出处(出具时已标为无出处)',
|
||||
numUncited: '该数字未使用引用标记,无法核对其来源',
|
||||
numUnresolved: (why) => `报告生成时这处引用未能解析${why ? `:${why}` : ''}`,
|
||||
numInput: '引用的是运行输入,运行输入未随文件附带',
|
||||
numOther: '这类引用不在核对范围内',
|
||||
numDocUntrusted: '所在的报告文档没有通过核对',
|
||||
numNoEntry: '报告的证据目录里没有这处引用指向的条目',
|
||||
numArtifactAbsent: '引用的工件未随文件附带',
|
||||
numArtifactFailed: '引用的工件没有通过哈希核对',
|
||||
numArtifactNotListed: '引用的工件不在文件的工件清单里:导出时不会出现这种情况,文件可能被改过',
|
||||
numArtifactFailed: '引用的工件没有通过核对(见核对明细 ③)',
|
||||
numArtifactUnparsable: '引用的工件哈希一致,但内容无法解析',
|
||||
numNotChained: '引用的工件不在引用链上',
|
||||
numNoMetric: '口径卡里没有这个指标',
|
||||
numMetricDiffers: '引用的值与口径卡里的值不一致',
|
||||
numNoCell: (why) => `快照里找不到这一格:${why}`,
|
||||
numCellDiffers: '引用的值与快照里那一格的值不一致',
|
||||
numAria: (text, result) => `数字 ${text},${result},查看出处`,
|
||||
numAria: (kind, text, result) => `${kind === 'value' ? '取值' : '数字'} ${text},${result},查看出处`,
|
||||
statusOk: (seq, what) => `与第 ${seq} 条事件(${what})一致`,
|
||||
statusMismatch: (claim, seq, what) => `文件里写的是「${claim}」,与封存范围内最后一条结束类事件(第 ${seq} 条,${what})不符`,
|
||||
statusLegacy: '早期运行在结束事件落库之前封存,结束事件不在封存范围内,运行结果无法核对',
|
||||
statusNoEvent: '封存范围内没有运行结束、失败或取消的事件,运行结果无法核对',
|
||||
classOk: (seq) => `与第 ${seq} 条事件(运行开始)记下的运行参数一致`,
|
||||
classMismatch: (claim, seq) => `文件里写的是「${claim}」,与第 ${seq} 条事件(运行开始)记下的运行参数不符`,
|
||||
classLegacy: (seq) => `第 ${seq} 条事件(运行开始)出自早期运行,没有记下区分运行类别的参数,运行类别无法核对`,
|
||||
classUnchecked: (seq) => `第 ${seq} 条事件(运行开始)的内容没有通过核对,运行类别无法核对`,
|
||||
classAbsent: (seq) => `第 ${seq} 条事件(运行开始)的内容未随文件附带:导出时不会出现这种情况,文件可能被改过`,
|
||||
classNoEvent: '封存范围内没有运行开始事件,运行类别无法核对',
|
||||
tierOk: (seq) => `与第 ${seq} 条事件(出具判定)一致`,
|
||||
tierMismatch: (claim, seq) => `文件里写的是「${claim}」,与封存范围内最后一条出具判定(第 ${seq} 条)不符`,
|
||||
tierNoEvent: (claim) => `文件里写着出具档位「${claim}」,封存范围内却没有出具判定`,
|
||||
tierUnchecked: (seq) => `第 ${seq} 条事件(出具判定)的内容没有通过核对,出具档位无法核对`,
|
||||
tierAbsent: (seq) => `第 ${seq} 条事件(出具判定)的内容未随文件附带:导出时不会出现这种情况,文件可能被改过`,
|
||||
metaNeedsManifest: '封存清单没有通过核对,事件的类型和序号不可信,无法对照',
|
||||
dResult: '核对结果', dSource: '出处', dNode: '来源节点', dCaliber: '口径', dCited: '引用的值', dCard: '口径卡里的值',
|
||||
dCell: '快照里的值', dShown: '正文显示', dShownNote: '显示格式由服务端渲染', dArtifact: '所在工件', dReport: '所在报告',
|
||||
dSql: 'SQL', dRow: (row) => `快照第 ${row + 1} 行`,
|
||||
@@ -221,6 +262,48 @@
|
||||
return out
|
||||
}
|
||||
|
||||
const NONFINITE = { NaN: NaN, Infinity: Infinity, '-Infinity': -Infinity }
|
||||
|
||||
/**
|
||||
* JSON.parse,另认 Python 的 json.dumps 写出的裸 NaN、Infinity、-Infinity(JSON 标准不认)。规范字符串和工件文件
|
||||
* 都是 json.dumps 写的,查询结果里浮点的 NaN 会原样落进去,标准的 JSON.parse 一读就报错,没被改过的文件也会被判成
|
||||
* 不通过。字符串外面的这几个词先换成占位的字符串再解析,解析时换回对应的数:判等和服务端一样,NaN 和谁都不相等。
|
||||
* 只用来读内容,哈希照旧按原字节算
|
||||
*/
|
||||
function parseJson(text) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch (e) {
|
||||
// 下面按宽松的认法再试一次
|
||||
}
|
||||
let mark = '\uE000nonfinite:'
|
||||
while (text.includes(mark)) mark += '\uE000'
|
||||
let out = ''
|
||||
let from = 0
|
||||
let inString = false
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i]
|
||||
if (inString) {
|
||||
if (c === '\\') i++
|
||||
else if (c === '"') inString = false
|
||||
continue
|
||||
}
|
||||
if (c === '"') {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
const word = c === '-' || c === 'I' || c === 'N' ? ['-Infinity', 'Infinity', 'NaN'].find((w) => text.startsWith(w, i)) : null
|
||||
if (word) {
|
||||
out += text.slice(from, i) + JSON.stringify(mark + word)
|
||||
i += word.length - 1
|
||||
from = i + 1
|
||||
}
|
||||
}
|
||||
out += text.slice(from)
|
||||
return JSON.parse(out, (_, v) => (typeof v === 'string' && v.startsWith(mark) && v.slice(mark.length) in NONFINITE
|
||||
? NONFINITE[v.slice(mark.length)] : v))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 判等:照搬 engine/expressions.py
|
||||
|
||||
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
@@ -319,10 +402,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function verify(data, digest) {
|
||||
const out = { events: [], manifest: null, artifacts: [], chain: [], numbers: [], docs: [], checked: new Map() }
|
||||
const events = Array.isArray(data.events) ? data.events : []
|
||||
const meta = isObj(data.meta) ? data.meta : {}
|
||||
/** shownSeal:页面顶部直接写出的封存哈希(服务端渲染进模板,不执行脚本也看得到的那一份) */
|
||||
async function verify(data, digest, shownSeal) {
|
||||
const out = {
|
||||
events: [], manifest: [], artifacts: [], chain: [], numbers: [], docs: [], meta: [], checked: new Map(), order: [],
|
||||
}
|
||||
const events = Array.isArray(data.events) ? data.events.filter(isObj) : []
|
||||
const meta = data.meta
|
||||
|
||||
// ① 带规范字符串的事件
|
||||
for (const ev of events) {
|
||||
@@ -332,7 +418,7 @@
|
||||
item.reason = T.eventMismatch
|
||||
} else {
|
||||
try {
|
||||
out.checked.set(ev, JSON.parse(ev.canonical))
|
||||
out.checked.set(ev, parseJson(ev.canonical))
|
||||
item.result = PASS
|
||||
} catch (e) {
|
||||
item.reason = T.eventNotJson
|
||||
@@ -343,19 +429,31 @@
|
||||
|
||||
// ② 封存清单:seq 相同的几条按文件里的先后(服务端按数据库的顺序给,和封存时一样)
|
||||
const order = events.map((ev, i) => [ev, i]).sort((x, y) => x[0].seq - y[0].seq || x[1] - y[1]).map(([ev]) => ev)
|
||||
out.order = order
|
||||
const lines = order.map((ev) => `${ev.seq}|${ev.type}|${ev.node_id == null ? '' : ev.node_id}|${ev.data_sha256}`)
|
||||
const computed = await digest(utf8(lines.join('\n')))
|
||||
const beyond = order.find((ev) => !(ev.seq <= meta.manifest_seq))
|
||||
out.manifest = {
|
||||
computed, count: lines.length, lines,
|
||||
result: computed === meta.manifest_hash && !beyond ? PASS : FAIL,
|
||||
reason: computed !== meta.manifest_hash ? T.manifestMismatch : beyond ? T.beyondSeal(beyond.seq) : T.manifestOk,
|
||||
const last = order[order.length - 1]
|
||||
const manifest = { kind: 'computed', computed, count: lines.length, lines, result: FAIL, reason: '' }
|
||||
if (computed !== meta.manifest_hash) manifest.reason = T.manifestMismatch
|
||||
else if (beyond) manifest.reason = T.beyondSeal(beyond.seq)
|
||||
// 新运行封存时记下的就是当时最后一条事件的序号(runner._seal);早期运行的范围是推算的,不一定落在某条事件上
|
||||
else if (!meta.legacy && last && last.seq !== meta.manifest_seq) manifest.reason = T.rangeMismatch(meta.manifest_seq, last.seq)
|
||||
else {
|
||||
manifest.result = PASS
|
||||
manifest.reason = T.manifestOk
|
||||
}
|
||||
out.manifest.push(manifest)
|
||||
// 页面顶部写出的那一份要和核对用的一致:不然改过事件、重算了数据里的哈希,顶上却留着原来那串,读的人拿它去系统
|
||||
// 里对照会对得上。脚本执行后顶上换成核对用的这一份
|
||||
const seal = String(meta.manifest_hash ?? '')
|
||||
out.manifest.push({ kind: 'shown', shown: shownSeal, result: shownSeal === seal ? PASS : FAIL,
|
||||
reason: shownSeal === seal ? T.shownOk : T.shownMismatch })
|
||||
|
||||
// ③ 工件
|
||||
// ③ 工件。服务端给每份工件的是三样之一:原字节、遮罩(只有查询快照)、服务端已没有文件
|
||||
const arts = new Map()
|
||||
for (const a of Array.isArray(data.artifacts) ? data.artifacts : []) {
|
||||
const item = { a, result: NA, reason: '', json: null, loose: null }
|
||||
for (const a of Array.isArray(data.artifacts) ? data.artifacts.filter(isObj) : []) {
|
||||
const item = { a, result: NA, reason: '', json: null, loose: null, parsed: false }
|
||||
if (typeof a.b64 === 'string') {
|
||||
let bytes = null
|
||||
try {
|
||||
@@ -368,23 +466,34 @@
|
||||
const intact = (await digest(bytes)) === a.id
|
||||
try {
|
||||
// 对不上的也解析出来(loose):被改过的报告仍显示正文,醒目地标着不可信
|
||||
item.loose = JSON.parse(decoder.decode(bytes))
|
||||
item.loose = parseJson(decoder.decode(bytes))
|
||||
item.parsed = true
|
||||
} catch (e) {
|
||||
item.loose = null
|
||||
}
|
||||
if (!intact) {
|
||||
item.result = FAIL
|
||||
item.reason = T.artifactMismatch
|
||||
} else if (item.loose === null) {
|
||||
item.result = FAIL
|
||||
item.reason = T.artifactNotJson
|
||||
} else if (!item.parsed) {
|
||||
// 哈希对得上,就是系统里存的那一份:读不懂不是被改过,照实说核对不了
|
||||
item.reason = T.artifactUnparsable
|
||||
} else {
|
||||
item.result = PASS
|
||||
item.json = item.loose
|
||||
}
|
||||
}
|
||||
} else if (a.masked) {
|
||||
if (a.kind === 'query') {
|
||||
item.reason = T.masked
|
||||
} else {
|
||||
item.result = FAIL
|
||||
item.reason = T.maskedOdd
|
||||
}
|
||||
} else if (a.missing) {
|
||||
item.reason = T.artifactMissing
|
||||
} else {
|
||||
item.reason = a.masked ? T.masked : a.missing ? T.artifactMissing : T.artifactAbsent
|
||||
item.result = FAIL
|
||||
item.reason = T.artifactAbsent
|
||||
}
|
||||
arts.set(a.id, item)
|
||||
out.artifacts.push(item)
|
||||
@@ -412,9 +521,13 @@
|
||||
if (!ev) link.reason = T.chainNoCheckEvent
|
||||
else if (!out.checked.has(ev)) link.reason = T.chainCheckEventFailed
|
||||
else if (own(out.checked.get(ev), 'doc_artifact') !== report.doc_artifact) link.reason = T.chainOtherDoc
|
||||
else if (!art || art.result !== PASS) { link.result = NA; link.reason = T.chainDocUnverified }
|
||||
else if (own(art.json, 'run_id') !== meta.run_id || own(art.json, 'node_id') !== report.node_id) link.reason = T.chainDocElsewhere
|
||||
else link.result = PASS
|
||||
else if (!art) link.reason = T.chainDocNotListed
|
||||
else if (art.result === FAIL) Object.assign(link, { result: NA, reason: T.chainDocUnverified })
|
||||
else if (art.result !== PASS) {
|
||||
Object.assign(link, { result: NA, reason: art.a.missing ? T.chainDocMissing : T.chainDocUnparsable })
|
||||
} else if (own(art.json, 'run_id') !== meta.run_id || own(art.json, 'node_id') !== report.node_id) {
|
||||
link.reason = T.chainDocElsewhere
|
||||
} else link.result = PASS
|
||||
out.chain.push(link)
|
||||
if (link.result === PASS) {
|
||||
trusted.set(report, art.json)
|
||||
@@ -447,11 +560,12 @@
|
||||
if (ok) chained.add(item.a.id)
|
||||
}
|
||||
|
||||
// ⑤ 报告里的数字
|
||||
// ⑤ 报告里的数字和取值
|
||||
for (const report of reports) {
|
||||
const art = arts.get(report.doc_artifact)
|
||||
const doc = trusted.get(report) || (art && isObj(art.loose) ? art.loose : null)
|
||||
out.docs.push({ report, art, doc, trusted: trusted.has(report) })
|
||||
const link = out.chain.find((c) => c.kind === 'report_doc' && c.report === report)
|
||||
const doc = trusted.get(report) || (art && art.parsed && isObj(art.loose) ? art.loose : null)
|
||||
out.docs.push({ report, art, link, doc, trusted: trusted.has(report) })
|
||||
if (!doc) continue
|
||||
for (const seg of segmentsOf(doc)) {
|
||||
if (seg.kind !== 'number' && seg.kind !== 'value') continue
|
||||
@@ -460,6 +574,9 @@
|
||||
out.numbers.push({ ...check, seg, report, key: `${report.node_id}|${seg.id}` })
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ 运行信息
|
||||
out.meta = checkMeta(meta, out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -493,9 +610,12 @@
|
||||
info.artifact = entry.artifact
|
||||
info.node = entry.node_id
|
||||
const art = arts.get(entry.artifact)
|
||||
if (!art) return { ...info, result: NA, reason: T.numArtifactAbsent }
|
||||
if (art.a.masked || art.a.missing) return { ...info, result: NA, reason: art.reason }
|
||||
if (art.result !== PASS) return { ...info, result: NA, reason: T.numArtifactFailed }
|
||||
// 服务端把已核对的文档引用到的口径卡、快照逐个列进清单(拿不到内容的也列,标明原因):不在清单里只能是被拿掉了
|
||||
if (!art) return { ...info, result: FAIL, reason: T.numArtifactNotListed }
|
||||
if (art.result === FAIL) return { ...info, result: NA, reason: T.numArtifactFailed }
|
||||
if (art.result !== PASS) {
|
||||
return { ...info, result: NA, reason: art.a.masked ? T.masked : art.a.missing ? T.artifactMissing : T.numArtifactUnparsable }
|
||||
}
|
||||
if (!chained.has(entry.artifact)) return { ...info, result: NA, reason: T.numNotChained }
|
||||
if (cite.kind === 'metric') {
|
||||
const metricId = own(cite.locator, 'metric') ?? own(entry.locator, 'metric')
|
||||
@@ -519,6 +639,74 @@
|
||||
return sameValue(cite.value, info.truth) ? { ...info, result: PASS } : { ...info, result: FAIL, reason: T.numCellDiffers }
|
||||
}
|
||||
|
||||
/**
|
||||
* ⑥ 运行信息:运行结果、运行类别、出具档位不信元信息,拿封存范围内已核对的事件对照,页面上显示的也是事件里的。
|
||||
* 事件的类型和序号由 ② 的清单绑定,清单没通过就无从对照。每项 { field, shown(显示的值), claim(元信息写的), result, reason }
|
||||
*/
|
||||
function checkMeta(meta, out) {
|
||||
const items = []
|
||||
const add = (field, shown, claim, result, reason) => items.push({ field, shown, claim, result, reason })
|
||||
const order = out.order
|
||||
const sealOk = out.manifest[0].result === PASS
|
||||
const label = (map, value) => map[value] || (value ? String(value) : '—')
|
||||
const what = (ev) => EVENT_LABEL[ev.type] || ev.type
|
||||
|
||||
// 运行结果:封存范围内最后一条结束类事件的类型。早期运行在 run.finished 落库之前封存,成功结束的那条在范围外,
|
||||
// 范围内最后一条也就不是结束类事件;失败、取消的那条在范围内
|
||||
const statusClaim = label(STATUS_LABEL, meta.status)
|
||||
const end = [...order].reverse().find((ev) => TERMINAL[ev.type])
|
||||
const lastEv = order[order.length - 1]
|
||||
if (!sealOk) add('status', statusClaim, statusClaim, NA, T.metaNeedsManifest)
|
||||
else if (meta.legacy && meta.status === 'succeeded' && !(lastEv && TERMINAL[lastEv.type])) {
|
||||
add('status', statusClaim, statusClaim, NA, T.statusLegacy)
|
||||
} else if (!end) add('status', statusClaim, statusClaim, NA, T.statusNoEvent)
|
||||
else {
|
||||
const ok = TERMINAL[end.type] === meta.status
|
||||
add('status', label(STATUS_LABEL, TERMINAL[end.type]), statusClaim, ok ? PASS : FAIL,
|
||||
ok ? T.statusOk(end.seq, what(end)) : T.statusMismatch(statusClaim, end.seq, what(end)))
|
||||
}
|
||||
|
||||
// 运行类别:运行开始事件记下的工具信任档。正式运行记 formal,探索运行记各工具的档位(一个对象),升级前的运行没有
|
||||
const classClaim = label(CLASS_LABEL, meta.run_class)
|
||||
const started = order.find((ev) => ev.type === 'run.started')
|
||||
if (!sealOk) add('class', classClaim, classClaim, NA, T.metaNeedsManifest)
|
||||
else if (!started) add('class', classClaim, classClaim, NA, T.classNoEvent)
|
||||
else if (typeof started.canonical !== 'string') add('class', classClaim, classClaim, FAIL, T.classAbsent(started.seq))
|
||||
else if (!out.checked.has(started)) add('class', classClaim, classClaim, NA, T.classUnchecked(started.seq))
|
||||
else {
|
||||
const trust = own(out.checked.get(started), 'tool_trust')
|
||||
const expected = trust === 'formal' ? 'formal' : isObj(trust) ? 'exploratory' : null
|
||||
if (!expected) add('class', classClaim, classClaim, NA, T.classLegacy(started.seq))
|
||||
else {
|
||||
const ok = expected === meta.run_class
|
||||
add('class', label(CLASS_LABEL, expected), classClaim, ok ? PASS : FAIL,
|
||||
ok ? T.classOk(started.seq) : T.classMismatch(classClaim, started.seq))
|
||||
}
|
||||
}
|
||||
|
||||
// 出具档位:封存范围内最后一条记下了档位的出具判定(和服务端 build._tier 同一个认法),还要是元信息指的那一条
|
||||
const tierClaim = isObj(meta.tier) ? label(TIER_LABEL, meta.tier.value) : '—'
|
||||
let found = null
|
||||
for (const ev of [...order].reverse()) {
|
||||
if (ev.type !== 'issuance') continue
|
||||
if (typeof ev.canonical !== 'string') found = { result: FAIL, reason: T.tierAbsent(ev.seq) }
|
||||
else if (!out.checked.has(ev)) found = { result: NA, reason: T.tierUnchecked(ev.seq) }
|
||||
else if (typeof own(out.checked.get(ev), 'tier') === 'string') found = { ev, value: out.checked.get(ev).tier }
|
||||
if (found) break
|
||||
}
|
||||
if (!sealOk) {
|
||||
if (meta.tier != null || found) add('tier', tierClaim, tierClaim, NA, T.metaNeedsManifest)
|
||||
} else if (!found) {
|
||||
if (meta.tier != null) add('tier', tierClaim, tierClaim, FAIL, T.tierNoEvent(tierClaim))
|
||||
} else if (!found.ev) add('tier', tierClaim, tierClaim, found.result, found.reason)
|
||||
else {
|
||||
const ok = isObj(meta.tier) && meta.tier.value === found.value && meta.tier.seq === found.ev.seq
|
||||
add('tier', label(TIER_LABEL, found.value), tierClaim, ok ? PASS : FAIL,
|
||||
ok ? T.tierOk(found.ev.seq) : T.tierMismatch(tierClaim, found.ev.seq))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 渲染:工具
|
||||
|
||||
const $ = (id) => document.getElementById(id)
|
||||
@@ -561,18 +749,29 @@
|
||||
return h('dl', { class: 'kv' }, rows.filter(Boolean).map(([k, v]) => [h('dt', {}, k), h('dd', {}, v)]))
|
||||
}
|
||||
|
||||
function worst(items) {
|
||||
/** 一组结果汇总成一个:有不通过就是不通过;全通过、全无法核对照实;通过和无法核对都有是「部分无法核对」 */
|
||||
function summarize(items) {
|
||||
if (!items.length) return NA
|
||||
if (items.some((i) => i.result === FAIL)) return FAIL
|
||||
if (items.some((i) => i.result === PASS)) return PASS
|
||||
return NA
|
||||
const pass = items.some((i) => i.result === PASS)
|
||||
const na = items.some((i) => i.result === NA)
|
||||
return pass && na ? PARTIAL : pass ? PASS : NA
|
||||
}
|
||||
|
||||
/** 分项计数「通过 24 · 无法核对 3」,是零的不写 */
|
||||
function tallyText(items) {
|
||||
return [PASS, FAIL, NA].map((r) => [r, items.filter((i) => i.result === r).length])
|
||||
.filter(([, n]) => n > 0).map(([r, n]) => `${RESULT[r]} ${n}`).join(' · ')
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 渲染:报告正文
|
||||
|
||||
// 行内语法:代码、链接(只显示文字,不给链接:页面不连网)、粗体、删除线。和前端 Markdown.tsx 的规则一致,
|
||||
// 斜体不认(要用后行断言,老浏览器不支持)
|
||||
const INLINE = '(`+)([^`]+?)\\1|\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)|\\*\\*([^*]+?)\\*\\*|__([^_]+?)__|~~([^~]+?)~~'
|
||||
const BIT = { code: 1, bold: 2, strike: 4, link: 8 }
|
||||
// 行内语法:代码、链接(只显示文字,不给链接:页面不连网)、粗体、斜体、删除线,和前端 run/Markdown.tsx 的规则一致。
|
||||
// 斜体要求左边不是 *(否则 **粗体** 会被它先拆掉):前端用后行断言,这里不用(较早的 Safari 不认,构造正则时
|
||||
// 就会报错,整页核对跟着失败),匹配到以后自己看左边那个字
|
||||
const INLINE = '(`+)([^`]+?)\\1|\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)|\\*\\*([^*]+?)\\*\\*|__([^_]+?)__'
|
||||
+ '|\\*([^*\\n]+?)\\*(?!\\*)|~~([^~]+?)~~'
|
||||
const BIT = { code: 1, bold: 2, strike: 4, link: 8, em: 16 }
|
||||
// 列表项的开头:行首的 -、*、+ 或者序号
|
||||
const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/
|
||||
|
||||
@@ -585,15 +784,20 @@
|
||||
const re = new RegExp(INLINE, 'g')
|
||||
let last = 0
|
||||
for (let m = re.exec(part); m; m = re.exec(part)) {
|
||||
const [whole, ticks, code, linkText, , bold1, bold2, italic, strike] = m
|
||||
if (italic !== undefined && m.index > 0 && part[m.index - 1] === '*') {
|
||||
// 左边紧挨着 *:不算斜体(前端后行断言排除的情形),从下一个字接着找
|
||||
re.lastIndex = m.index + 1
|
||||
continue
|
||||
}
|
||||
for (let i = last; i < m.index; i++) style[from + i] = inherited
|
||||
const [whole, ticks, code, linkText, , bold1, bold2, strike] = m
|
||||
const inner = code ?? linkText ?? bold1 ?? bold2 ?? strike ?? ''
|
||||
const inner = code ?? linkText ?? bold1 ?? bold2 ?? italic ?? strike ?? ''
|
||||
const base = from + m.index
|
||||
const off = code !== undefined ? ticks.length : whole.indexOf(inner)
|
||||
for (let i = 0; i < whole.length; i++) hide[base + i] = 1
|
||||
for (let i = 0; i < inner.length; i++) hide[base + off + i] = 0
|
||||
const bit = code !== undefined ? BIT.code : linkText !== undefined ? BIT.link
|
||||
: bold1 !== undefined || bold2 !== undefined ? BIT.bold : BIT.strike
|
||||
: bold1 !== undefined || bold2 !== undefined ? BIT.bold : italic !== undefined ? BIT.em : BIT.strike
|
||||
if (code !== undefined || linkText !== undefined || depth >= 3) {
|
||||
for (let i = 0; i < inner.length; i++) style[base + off + i] = inherited | bit
|
||||
} else {
|
||||
@@ -617,6 +821,7 @@
|
||||
while (j < to && !styles.hide[j] && styles.style[j] === st) j++
|
||||
let node = document.createTextNode(text.slice(i, j))
|
||||
if (st & BIT.code) node = h('code', {}, node)
|
||||
if (st & BIT.em) node = h('em', {}, node)
|
||||
if (st & BIT.bold) node = h('strong', {}, node)
|
||||
if (st & BIT.strike) node = h('s', {}, node)
|
||||
if (st & BIT.link) node = h('span', { class: 'link' }, node)
|
||||
@@ -724,9 +929,10 @@
|
||||
const eventWhat = (ev) => [EVENT_LABEL[ev.type] || ev.type, ev.node_id ? nodeLabel(ev.node_id) : null].filter(Boolean).join(' · ')
|
||||
|
||||
function numberButton(number, body) {
|
||||
const kind = number.seg.kind === 'value' ? 'value' : 'number'
|
||||
const btn = h('button', {
|
||||
type: 'button', class: 'num', 'data-num': number.key, 'data-result': number.result,
|
||||
'aria-label': T.numAria(String(number.seg.text ?? ''), RESULT[number.result]),
|
||||
type: 'button', class: 'num', 'data-num': number.key, 'data-kind': kind, 'data-result': number.result,
|
||||
'aria-label': T.numAria(kind, String(number.seg.text ?? ''), RESULT[number.result]),
|
||||
onclick: () => openNumber(number),
|
||||
}, body)
|
||||
number.button = btn
|
||||
@@ -742,6 +948,8 @@
|
||||
function detailOf(number) {
|
||||
const cite = isObj(number.seg.cite) ? number.seg.cite : null
|
||||
const card = number.card
|
||||
// 只有带出处的数字是服务端按引用的值渲染的;模型自己写的数、表格里的文字取值都是原文
|
||||
const rendered = number.seg.kind === 'number' && cite && cite.status === 'resolved'
|
||||
const rows = [
|
||||
[T.dResult, [badge(number.result), number.reason ? ` ${number.reason}` : '']],
|
||||
[T.dSource, number.source],
|
||||
@@ -749,7 +957,7 @@
|
||||
card ? [T.dCaliber, [card.caliber || '—', card.caliber_version ? ` ${card.caliber_version}` : ''].join('')] : null,
|
||||
cite && cite.status === 'resolved' && 'value' in cite ? [T.dCited, valueText(cite.value)] : null,
|
||||
'truth' in number ? [number.kind === 'metric' ? T.dCard : T.dCell, valueText(number.truth)] : null,
|
||||
[T.dShown, [String(number.seg.text ?? ''), h('span', { class: 'muted' }, `(${T.dShownNote})`)]],
|
||||
[T.dShown, [String(number.seg.text ?? ''), rendered ? h('span', { class: 'muted' }, `(${T.dShownNote})`) : null]],
|
||||
number.artifact ? [T.dArtifact, [h('code', {}, short(number.artifact)), artifactState(number.artifact)]] : null,
|
||||
[T.dReport, nodeLabel(number.report.node_id)],
|
||||
]
|
||||
@@ -780,11 +988,19 @@
|
||||
$('detail-body').replaceChildren(...detailOf(number))
|
||||
panel.hidden = false
|
||||
panel.setAttribute('data-open', number.key)
|
||||
// 宽屏上正文给面板让出右边一栏(样式表 html.detail-open):版面变窄、换了行,点的数字可能挪了位置
|
||||
document.documentElement.classList.add('detail-open')
|
||||
$('detail-close').focus({ preventScroll: true })
|
||||
// 窄屏上面板从底部抽出:点的数字被盖住时把它滚到面板上方
|
||||
if (!view.active) return
|
||||
const sheet = panel.getBoundingClientRect()
|
||||
const spot = view.active ? view.active.getBoundingClientRect() : null
|
||||
if (spot && sheet.top > 0 && spot.bottom > sheet.top) window.scrollBy({ top: spot.top - Math.max(16, sheet.top / 3) })
|
||||
const spot = view.active.getBoundingClientRect()
|
||||
if (sheet.top > 0) {
|
||||
// 窄屏上面板从底部抽出:点的数字被盖住时把它滚到面板上方
|
||||
if (spot.bottom > sheet.top) window.scrollBy({ top: spot.top - Math.max(16, sheet.top / 3) })
|
||||
} else {
|
||||
// 宽屏:让开之后数字还在视野里(表格横向滚动时也滚到它)
|
||||
view.active.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
@@ -792,6 +1008,7 @@
|
||||
if (panel.hidden) return
|
||||
panel.hidden = true
|
||||
panel.removeAttribute('data-open')
|
||||
document.documentElement.classList.remove('detail-open')
|
||||
if (view.active) view.active.classList.remove('is-active')
|
||||
if (view.back && typeof view.back.focus === 'function') view.back.focus()
|
||||
view.active = null
|
||||
@@ -799,33 +1016,50 @@
|
||||
|
||||
// ------------------------------------------------------------------ 渲染:页面各块
|
||||
|
||||
function renderHead(data) {
|
||||
function rowsOf(rows) {
|
||||
return rows.filter(Boolean).flatMap(([k, v]) => [h('dt', {}, k), h('dd', {}, v)])
|
||||
}
|
||||
|
||||
function renderHead(data, out) {
|
||||
const meta = data.meta
|
||||
$('title').textContent = String(meta.workflow_name || '')
|
||||
// 顶上换成核对用的这一份(② 已经核过它和原来写的是否一致)
|
||||
$('seal-hash').textContent = String(meta.manifest_hash || '')
|
||||
$('seal-note').textContent = T.sealNote(String(meta.run_id || ''))
|
||||
let tier = '—'
|
||||
if (isObj(meta.tier)) {
|
||||
const ev = data.events.find((e) => e.seq === meta.tier.seq && e.type === 'issuance')
|
||||
const body = ev ? view.out.checked.get(ev) : undefined
|
||||
const ok = isObj(body) && body.tier === meta.tier.value
|
||||
tier = h('span', { 'data-tier': ok ? 'checked' : 'unchecked' }, TIER_LABEL[meta.tier.value] || String(meta.tier.value),
|
||||
h('span', { class: ok ? 'muted' : 'bad' }, `(${ok ? T.tierChecked(meta.tier.seq) : T.tierUnchecked})`))
|
||||
for (const el of document.querySelectorAll('.run-id')) el.textContent = String(meta.run_id || '')
|
||||
const checked = (field, fallback) => {
|
||||
const item = out.meta.find((m) => m.field === field)
|
||||
if (!item) return fallback
|
||||
const note = item.result === PASS ? T.markPass : item.result === FAIL ? T.markFail(item.claim) : T.markNa
|
||||
return h('span', { 'data-meta': field, 'data-result': item.result }, item.shown,
|
||||
h('span', { class: item.result === FAIL ? 'bad' : 'muted' }, `(${note})`))
|
||||
}
|
||||
const runClass = CLASS_LABEL[meta.run_class] || meta.run_class || '—'
|
||||
const rows = [
|
||||
$('meta').replaceChildren(...rowsOf([
|
||||
[T.metaRun, h('code', {}, String(meta.run_id || ''))],
|
||||
[T.metaStatus, checked('status', '—')],
|
||||
[T.metaClass, checked('class', '—')],
|
||||
[T.metaTier, checked('tier', '—')],
|
||||
[T.metaRange, `${T.range(meta.manifest_seq, out.order.length)}${meta.legacy ? T.rangeLegacy : ''}`],
|
||||
[T.metaExported, when(meta.exported_at)],
|
||||
]))
|
||||
// 绑不进封存的几样单独一组,写明出处:改了它们,五项核对照样通过,去系统里对照封存哈希也发现不了
|
||||
const note = $('record-note')
|
||||
note.textContent = T.recordNote
|
||||
note.hidden = false
|
||||
$('record').replaceChildren(...rowsOf([
|
||||
[T.metaWorkflow, String(meta.workflow_name || '—')],
|
||||
[T.metaClass, meta.version != null ? `${runClass} v${meta.version}` : runClass],
|
||||
meta.status ? [T.metaStatus, STATUS_LABEL[meta.status] || String(meta.status)] : null,
|
||||
meta.version != null ? [T.metaVersion, `v${meta.version}`] : null,
|
||||
meta.version_hash ? [T.metaVersionHash, h('code', {}, String(meta.version_hash))] : null,
|
||||
[T.metaStarted, when(meta.started_at)],
|
||||
[T.metaFinished, when(meta.finished_at)],
|
||||
[T.metaRange, `${T.range(meta.manifest_seq, data.events.length)}${meta.legacy ? T.rangeLegacy : ''}`],
|
||||
[T.metaTier, tier],
|
||||
[T.metaExported, when(meta.exported_at)],
|
||||
]
|
||||
$('meta').replaceChildren(...rows.filter(Boolean).flatMap(([k, v]) => [h('dt', {}, k), h('dd', {}, v)]))
|
||||
]))
|
||||
}
|
||||
|
||||
/** 报告文档没核对上的原因:{ key, text }。key 进 data-why,服务端已没有文件、读不懂的用中性色,不是红的 */
|
||||
function docWhy(item) {
|
||||
if (!item.art) return { key: 'absent', text: T.docAbsent }
|
||||
if (item.art.result === FAIL || (item.link && item.link.result === FAIL)) return { key: 'untrusted', text: T.docUntrusted }
|
||||
if (item.art.a.missing) return { key: 'missing', text: T.docMissing }
|
||||
return { key: 'unreadable', text: T.docUnreadable }
|
||||
}
|
||||
|
||||
function renderReports(out) {
|
||||
@@ -837,31 +1071,42 @@
|
||||
const numbers = new Map(out.numbers.map((n) => [n.key, n]))
|
||||
for (const item of out.docs) {
|
||||
const label = nodeLabel(item.report.node_id)
|
||||
const link = out.chain.find((c) => c.kind === 'report_doc' && c.report === item.report)
|
||||
const result = worst([item.art || { result: NA }, link || { result: NA }])
|
||||
// 报告的徽标连同正文里的数字一起算:重算了全部哈希的伪造只在 ⑤ 露馅,标题旁不能还写着通过
|
||||
const nums = out.numbers.filter((n) => n.report === item.report)
|
||||
const docResult = [item.art, item.link].some((x) => x && x.result === FAIL) || !item.art ? FAIL : item.trusted ? PASS : NA
|
||||
const result = summarize([{ result: docResult }, ...nums])
|
||||
const box = h('article', { class: 'report', 'data-report': item.report.node_id, 'data-result': result },
|
||||
h('header', { class: 'report-head' }, h('h3', {}, T.reportTitle(label)), badge(result),
|
||||
h('span', { class: 'mono muted' }, short(item.report.doc_artifact))))
|
||||
if (!item.trusted) box.append(h('p', { class: 'warn' }, item.doc ? T.docUntrusted : T.docUnreadable))
|
||||
h('span', { class: 'mono muted' }, short(item.report.doc_artifact)),
|
||||
h('span', { class: 'report-parts', 'data-report-parts': '' },
|
||||
T.reportParts(RESULT[docResult], nums.length ? tallyText(nums) : T.reportNoNumbers))))
|
||||
if (!item.trusted) {
|
||||
const why = docWhy(item)
|
||||
box.append(h('p', { class: 'warn', 'data-why': why.key }, why.text))
|
||||
}
|
||||
if (item.doc) box.append(renderDoc(item.doc, { report: item.report, numbers }))
|
||||
root.append(box)
|
||||
}
|
||||
}
|
||||
|
||||
function checkBlock(id, title, summary, items, line) {
|
||||
const result = items.length ? worst(items) : NA
|
||||
const result = summarize(items)
|
||||
const tally = items.length > 1 ? tallyText(items) : ''
|
||||
const block = h('section', { class: 'check', 'data-check': id, 'data-result': result },
|
||||
h('div', { class: 'check-head' }, badge(result), h('h3', {}, title), h('span', { class: 'check-sum' }, summary)))
|
||||
h('div', { class: 'check-head' }, badge(result), h('h3', {}, title),
|
||||
h('span', { class: 'check-sum' }, tally ? `${summary} · ${tally}` : summary)))
|
||||
const list = (rows) => h('ul', { class: 'items' }, rows.map((item) => {
|
||||
const li = h('li', { 'data-result': item.result }, badge(item.result), h('span', { class: 'what' }, line(item)))
|
||||
if (item.reason) li.append(h('span', { class: 'why' }, item.reason))
|
||||
return li
|
||||
}))
|
||||
if (items.length <= 20) {
|
||||
if (items.length <= FOLD_AT) {
|
||||
block.append(list(items))
|
||||
} else {
|
||||
// 条目多(事件常有几百条):先列没通过的,全部的收起来
|
||||
block.append(list(items.filter((i) => i.result === FAIL)))
|
||||
// 条目多(事件、数字常有几百条):不通过的和无法核对的列在外面(不通过的在前),没核到的最需要被看见;
|
||||
// 全部的收起来
|
||||
const open = [...items.filter((i) => i.result === FAIL), ...items.filter((i) => i.result === NA)]
|
||||
if (open.length) block.append(list(open))
|
||||
block.append(h('details', {}, h('summary', {}, T.showAll(items.length)), list(items)))
|
||||
}
|
||||
return block
|
||||
@@ -871,12 +1116,12 @@
|
||||
const root = $('checks')
|
||||
root.append(checkBlock('events', T.checkEvents, T.sumEvents(out.events.length), out.events,
|
||||
(i) => `${T.chainByEvent(i.ev.seq, eventWhat(i.ev))}`))
|
||||
const m = out.manifest
|
||||
const manifest = checkBlock('manifest', T.checkManifest, T.sumManifest(m.count), [m],
|
||||
() => [T.manifestComputed, ' ', h('code', {}, m.computed)])
|
||||
const computed = out.manifest[0]
|
||||
const manifest = checkBlock('manifest', T.checkManifest, T.sumManifest(computed.count), out.manifest,
|
||||
(m) => [m.kind === 'computed' ? T.manifestComputed : T.manifestShown, ' ', h('code', {}, m.kind === 'computed' ? m.computed : m.shown || '—')])
|
||||
// 参与核对的原文照拼法列出来:读的人不信这一页的脚本,也能复制出去用别的工具算一遍
|
||||
manifest.append(h('details', {}, h('summary', {}, T.manifestLines(m.count)),
|
||||
h('pre', { class: 'sql', 'data-manifest-lines': '' }, m.lines.join('\n'))))
|
||||
manifest.append(h('details', {}, h('summary', {}, T.manifestLines(computed.count)),
|
||||
h('pre', { class: 'sql', 'data-manifest-lines': '' }, computed.lines.join('\n'))))
|
||||
root.append(manifest)
|
||||
root.append(checkBlock('artifacts', T.checkArtifacts, T.sumCount(out.artifacts.length, '份'), out.artifacts,
|
||||
(i) => [KIND_LABEL[i.a.kind] || i.a.kind, i.a.node_id ? ` · ${nodeLabel(i.a.node_id)}` : '', ' · ',
|
||||
@@ -891,11 +1136,14 @@
|
||||
return [KIND_LABEL[c.kind] || c.kind, ' ', h('code', {}, short(c.art.a.id)),
|
||||
refs.length ? ` ← ${T.chainFrom}${refs.join('、')}` : '']
|
||||
}))
|
||||
root.append(checkBlock('numbers', T.checkNumbers, T.sumCount(out.numbers.length, '个'), out.numbers,
|
||||
const values = out.numbers.filter((n) => n.seg.kind === 'value').length
|
||||
root.append(checkBlock('numbers', T.checkNumbers, T.sumNumbers(out.numbers.length - values, values), out.numbers,
|
||||
(n) => h('button', { type: 'button', 'data-num-item': n.key, onclick: () => {
|
||||
if (n.button) n.button.scrollIntoView({ block: 'center' })
|
||||
openNumber(n)
|
||||
} }, `${String(n.seg.text ?? '')} · ${n.source}`)))
|
||||
const metaLabel = { status: T.metaStatus, class: T.metaClass, tier: T.metaTier }
|
||||
root.append(checkBlock('meta', T.checkMeta, T.sumMeta, out.meta, (m) => `${metaLabel[m.field]}:${m.shown}`))
|
||||
}
|
||||
|
||||
function renderAppended(data) {
|
||||
@@ -909,14 +1157,16 @@
|
||||
}
|
||||
|
||||
function renderOverall(out) {
|
||||
const all = [...out.events, out.manifest, ...out.artifacts, ...out.chain, ...out.numbers]
|
||||
const all = [...out.events, ...out.manifest, ...out.artifacts, ...out.chain, ...out.numbers, ...out.meta]
|
||||
const count = (r) => all.filter((i) => i.result === r).length
|
||||
const [pass, fail, na] = [count(PASS), count(FAIL), count(NA)]
|
||||
const result = fail ? FAIL : PASS
|
||||
// 报告文档核对不了(导出时服务端已没有文件、读不懂):页面上没有可核的正文,不能写成绿色的「通过」
|
||||
const unverified = out.docs.filter((d) => !d.trusted).length
|
||||
const result = fail ? FAIL : unverified ? 'incomplete' : PASS
|
||||
const box = $('overall')
|
||||
box.setAttribute('data-overall', result)
|
||||
box.querySelector('.overall-mark').textContent = MARK[result]
|
||||
$('overall-text').textContent = fail ? T.failed(fail) : na ? T.passWithNa : T.allPass
|
||||
box.querySelector('.overall-mark').textContent = fail ? MARK.fail : unverified ? MARK.na : MARK.pass
|
||||
$('overall-text').textContent = fail ? T.failed(fail) : unverified ? T.incomplete(unverified) : na ? T.passWithNa : T.allPass
|
||||
$('overall-sub').textContent = T.tally(pass, fail, na)
|
||||
}
|
||||
|
||||
@@ -951,8 +1201,8 @@
|
||||
fatal(T.selfTestFailed)
|
||||
return
|
||||
}
|
||||
view.out = await verify(data, picked.digest)
|
||||
renderHead(data)
|
||||
view.out = await verify(data, picked.digest, $('seal-hash').textContent.trim())
|
||||
renderHead(data, view.out)
|
||||
renderReports(view.out)
|
||||
renderChecks(view.out)
|
||||
renderAppended(data)
|
||||
@@ -963,8 +1213,12 @@
|
||||
document.documentElement.setAttribute('data-verified', '')
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
// 脚本在 head 里:一开始执行就标上 data-js,样式表据此把「尚未核对」换成「正在核对」;文档解析完再核对
|
||||
document.documentElement.setAttribute('data-js', '')
|
||||
const start = () => main().catch((e) => {
|
||||
console.error(e)
|
||||
fatal(T.broken)
|
||||
})
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true })
|
||||
else start()
|
||||
})()
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""可验证报告的虚构样本页:给 scripts/check-bundle.mjs 在浏览器里核对(pytest 不执行页面上的核对脚本)。
|
||||
|
||||
python -m tests.bundle_samples <目录>
|
||||
|
||||
在目录里写下几份页面(<名字>.html)。每份都是 build_bundle + render_html 真实生成的,封存哈希按 runner 的算法
|
||||
(artifact_store.manifest_hash)对事件真实算出,报告文档用 compose_doc 按证据目录真实组装(标题、段落、列表、
|
||||
表格、粗体、斜体,数字和取值的引用都和真实运行一样)。check-bundle 断网打开它们,逐项断言核对结果和原因,再在
|
||||
这几份上做篡改。这样遮罩快照、多份报告、早期运行、服务端缺文件这些分支,不论真数据里有没有,都有人在浏览器里核过。
|
||||
|
||||
数据全是虚构的:门店、会员、金额都是编的。
|
||||
|
||||
- base:一份报告,引用口径卡、门店流水(没有遮罩)、会员消费(有遮罩)各一处,一个没有出处的数,一张门店流水整表
|
||||
(数字列右对齐,数字与取值共 21 个,超过折叠的条数);一条封存之后追加的按需裁判
|
||||
- two-reports:另有一份月报,两份报告各自的引用链
|
||||
- legacy:早期运行,封存范围按结束事件推算(运行结束事件在范围外,运行结果无法核对)
|
||||
- failed:失败结束的运行(没有出具判定),封存说明多一句「继续运行会重新封存」
|
||||
- degraded:两条出具判定,先完整出具、后降档出具,认最后一条
|
||||
- missing-doc / missing-card:导出时服务端已没有报告文档 / 口径卡的文件
|
||||
- nan:运行开始事件和门店流水快照里有 json.dumps 写出的裸 NaN(快照里那一列报告没有引用)
|
||||
- stray:报告自查记下的文档是另一次运行写的
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.bundle.build import build_bundle, render_html
|
||||
from app.core.artifact_store import canonical_json, content_hash, manifest_hash
|
||||
from app.engine.evidence import build_catalog, compose_doc
|
||||
|
||||
RUN_ID = "5a3c0f4b9d2e4e1f8b7a6c5d4e3f2a1b"
|
||||
RUN = {"id": RUN_ID, "workflow_id": "wf-sample-weekly", "workflow_name": "门店周报", "version": 3,
|
||||
"version_hash": "ab" * 32, "run_class": "formal", "status": "succeeded",
|
||||
"started_at": "2026-09-07T01:00:00+00:00", "finished_at": "2026-09-07T01:02:03+00:00"}
|
||||
GRAPH = {"nodes": [{"id": n, "type": t, "data": {"label": label}} for n, t, label in [
|
||||
("start", "input", "开始"), ("fetch", "tool", "门店流水"), ("members", "tool", "会员消费"),
|
||||
("card", "metrics", "周报口径"), ("write", "report", "写周报"), ("monthly", "report", "写月报"),
|
||||
("out", "output", "成果")]]}
|
||||
EXPORTED_AT = "2026-10-05T08:00:00+00:00"
|
||||
|
||||
STORES = [["青石店", 1280.5], ["白沙店", "960.00"], ["临川店", 732], ["望江店", 1105.25], ["枫桥店", 418],
|
||||
["云岭店", 2210], ["石桥店", 655.5], ["柳湾店", 980]]
|
||||
CRM = {"columns": ["name", "phone", "spend"], "column_types": {"name": "text", "phone": "text", "spend": "number"},
|
||||
"rows": [["林一", "000-0001", 320.0], ["周二", "000-0002", 188.5]], "source": "crm",
|
||||
"sql": "SELECT name, phone, spend FROM members ORDER BY id", "truncated": False, "mask_columns": ["phone"]}
|
||||
CARD = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2",
|
||||
"metrics": [{"id": "gmv", "name": "销售额", "value": 8381.25, "unit": "元", "decimals": 2, "format": "thousands"}]}
|
||||
|
||||
WEEKLY = ("## 门店周报\n\n本周销售额 [[m:gmv]],*按发生周统计*。青石店流水 [[v:Q1.r0.amount]],白沙店 [[v:Q1.r1.amount]],"
|
||||
"**会员消费** [[v:Q2.r0.spend]],另有 42 笔退款待入账。[[see:Q1]]\n\n- 云岭店流水最高\n- 枫桥店流水最低\n\n"
|
||||
"[[table:Q1 cols=store,amount rows=0-7]]")
|
||||
MONTHLY = "## 门店月报\n\n本月销售额 [[m:gmv]],青石店流水 [[v:Q1.r0.amount]]。[[see:Q1]]"
|
||||
|
||||
|
||||
def stored(obj: Any) -> tuple[str, bytes]:
|
||||
"""照工件库的样子落一份:规范 JSON 的字节,id 是它的 sha256。"""
|
||||
raw = canonical_json(obj).encode()
|
||||
return content_hash(raw), raw
|
||||
|
||||
|
||||
def shop(nan: bool = False) -> dict[str, Any]:
|
||||
"""门店流水的查询快照。ratio 那一列报告不引用;nan=True 时第一行是浮点 NaN(json.dumps 写成裸的 NaN)。"""
|
||||
rows = [[name, amount, float("nan") if nan and i == 0 else round(0.05 * (i + 1), 2)]
|
||||
for i, (name, amount) in enumerate(STORES)]
|
||||
return {"columns": ["store", "amount", "ratio"],
|
||||
"column_types": {"store": "text", "amount": "number", "ratio": "number"}, "rows": rows, "source": "shop",
|
||||
"sql": "SELECT store, amount, ratio FROM orders ORDER BY id", "truncated": False}
|
||||
|
||||
|
||||
class Sample:
|
||||
"""一次虚构的已封存运行:工件(原字节)、证据目录、报告文档、事件。"""
|
||||
|
||||
def __init__(self, *, nan: bool = False) -> None:
|
||||
self.files: dict[str, bytes] = {}
|
||||
self.shop_id = self.put(shop(nan))
|
||||
self.crm_id = self.put(CRM)
|
||||
self.card_id = self.put(CARD)
|
||||
self.ledger = [
|
||||
{"kind": "query", "node_id": "fetch", "exec": 1, "artifact": self.shop_id, "tool": "db_query__shop",
|
||||
"source": "shop", "columns": ["store", "amount", "ratio"], "rows": len(STORES), "truncated": False},
|
||||
{"kind": "query", "node_id": "members", "exec": 1, "artifact": self.crm_id, "tool": "db_query__crm",
|
||||
"source": "crm", "columns": CRM["columns"], "rows": len(CRM["rows"]), "truncated": False},
|
||||
{"kind": "metric_set", "node_id": "card", "artifact": self.card_id},
|
||||
]
|
||||
self.catalog = build_catalog(nodes={"card": CARD}, ledger=self.ledger)
|
||||
|
||||
def put(self, obj: Any) -> str:
|
||||
artifact, raw = stored(obj)
|
||||
self.files[artifact] = raw
|
||||
return artifact
|
||||
|
||||
def load(self, artifact: str) -> Any:
|
||||
raw = self.files.get(artifact)
|
||||
return json.loads(raw) if raw is not None else None
|
||||
|
||||
def doc(self, text: str, node_id: str, run_id: str = RUN_ID) -> str:
|
||||
return self.put(compose_doc(text, self.catalog, node_id=node_id, run_id=run_id, loader=self.load))
|
||||
|
||||
def events(self, reports: dict[str, str], *, ending: str = "finished", tiers: tuple[str, ...] = ("formal",),
|
||||
nan: bool = False) -> tuple[list[tuple[int, str, str | None, Any]], int]:
|
||||
"""(全部事件, 封存范围)。封存范围到结束事件为止,后面再追加一条按需裁判。"""
|
||||
rows: list[tuple[int, str, str | None, Any]] = []
|
||||
|
||||
def add(etype: str, node_id: str | None, data: dict[str, Any]) -> None:
|
||||
rows.append((len(rows) + 1, etype, node_id, data))
|
||||
|
||||
add("run.started", None, {"nodes": len(GRAPH["nodes"]), "resumed": False, "memory_scope": "default",
|
||||
"collection": "default", "approval_default": None, "tool_trust": "formal",
|
||||
"agent_limits": None, "data_versions": None, "replay_protocol": 2,
|
||||
**({"sample_rate": float("nan")} if nan else {})})
|
||||
add("node.started", "start", {"node_type": "input", "label": "开始"})
|
||||
add("node.finished", "start", {"duration_ms": 1, "attempt": 1, "preview": {"week": "2026-W37"}})
|
||||
add("node.started", "fetch", {"node_type": "tool", "label": "门店流水"})
|
||||
add("tool.end", "fetch", {"tool": "db_query__shop", "duration_ms": 12, "query_artifact": self.shop_id,
|
||||
"preview": json.dumps({"columns": ["store", "amount"], "rows": STORES[:2]},
|
||||
ensure_ascii=False)})
|
||||
add("node.finished", "fetch", {"duration_ms": 14, "attempt": 1, "preview": "{\"rows\": …}",
|
||||
"evidence": [self.ledger[0]]})
|
||||
add("tool.end", "members", {"tool": "db_query__crm", "duration_ms": 9, "query_artifact": self.crm_id,
|
||||
"preview": json.dumps(CRM["rows"][:1], ensure_ascii=False)})
|
||||
add("node.finished", "members", {"duration_ms": 10, "attempt": 1, "evidence": [self.ledger[1]]})
|
||||
add("node.finished", "card", {"duration_ms": 3, "attempt": 1, "preview": {"kind": "metric_set"},
|
||||
"evidence": [self.ledger[2]]})
|
||||
for node_id, doc_id in reports.items():
|
||||
add("llm.end", node_id, {"model": "mock", "output": "(写作草稿)"})
|
||||
add("report.checked", node_id, {"doc_artifact": doc_id, "ok": True})
|
||||
add("node.finished", node_id, {"duration_ms": 20, "attempt": 1,
|
||||
"preview": {"text": "(报告正文)", "doc_artifact": doc_id}})
|
||||
if ending == "finished":
|
||||
for tier in tiers:
|
||||
add("issuance", "out", {"tier": tier, "mode": "citations"})
|
||||
add("node.finished", "out", {"duration_ms": 2, "attempt": 1, "preview": {"周报": "(报告正文)"}})
|
||||
add("run.finished", None, {"output": {"周报": "(报告正文)"}})
|
||||
else:
|
||||
add("node.failed", "out", {"error": "出口复核没有通过"})
|
||||
add("run.failed", None, {"error": "出口复核没有通过"})
|
||||
bound = len(rows)
|
||||
add("evidence.judged", next(iter(reports), None),
|
||||
{"verdicts": {"u1": {"verdict": "supported"}}, "model": "judge-x", "post_seal": True})
|
||||
return rows, bound
|
||||
|
||||
|
||||
def render(sample: Sample, events: list[tuple[int, str, str | None, Any]], bound: int, *, run: dict[str, Any] | None = None,
|
||||
legacy: bool = False, drop: tuple[str, ...] = ()) -> str:
|
||||
"""真实封存哈希的页面。drop:导出时服务端已没有文件的工件。"""
|
||||
sealed = [e for e in events if e[0] <= bound]
|
||||
files = {k: v for k, v in sample.files.items() if k not in drop}
|
||||
bundle = build_bundle(run or RUN, events, manifest_seq=bound, manifest_hash=manifest_hash(sealed), read=files.get,
|
||||
exported_at=EXPORTED_AT, legacy=legacy, graph=GRAPH, source_masks={})
|
||||
return render_html(bundle)
|
||||
|
||||
|
||||
def pages() -> dict[str, str]:
|
||||
"""{页面名: HTML}"""
|
||||
out: dict[str, str] = {}
|
||||
s = Sample()
|
||||
weekly = s.doc(WEEKLY, "write")
|
||||
events, bound = s.events({"write": weekly})
|
||||
out["base"] = render(s, events, bound)
|
||||
out["missing-doc"] = render(s, events, bound, drop=(weekly,))
|
||||
out["missing-card"] = render(s, events, bound, drop=(s.card_id,))
|
||||
# 早期运行:运行结束事件落库之前封存,范围到它前一条为止(runner.verify_manifest 的老口径)
|
||||
finished = next(seq for seq, etype, _, _ in events if etype == "run.finished")
|
||||
out["legacy"] = render(s, events, finished - 1, legacy=True)
|
||||
|
||||
monthly = s.doc(MONTHLY, "monthly")
|
||||
events, bound = s.events({"write": weekly, "monthly": monthly})
|
||||
out["two-reports"] = render(s, events, bound)
|
||||
|
||||
events, bound = s.events({"write": weekly}, ending="failed")
|
||||
out["failed"] = render(s, events, bound, run={**RUN, "status": "failed"})
|
||||
|
||||
events, bound = s.events({"write": weekly}, tiers=("formal", "degraded"))
|
||||
out["degraded"] = render(s, events, bound)
|
||||
|
||||
stray = s.doc(WEEKLY, "write", run_id="another-run-0000")
|
||||
events, bound = s.events({"write": stray})
|
||||
out["stray"] = render(s, events, bound)
|
||||
|
||||
n = Sample(nan=True)
|
||||
events, bound = n.events({"write": n.doc(WEEKLY, "write")}, nan=True)
|
||||
out["nan"] = render(n, events, bound)
|
||||
return out
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
print("用法:python -m tests.bundle_samples <目录>", file=sys.stderr)
|
||||
return 2
|
||||
target = Path(argv[1])
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for name, page in pages().items():
|
||||
(target / f"{name}.html").write_text(page, encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
- 只给已封存且封存核对一致的运行:没封存、封存后被改过都回 409;
|
||||
- 导出的数据里,事件行按浏览器的拼法重算等于运行记下的 manifest_hash;
|
||||
- 遮罩库的快照只有 id 没有内容,它的查询事件只有哈希;
|
||||
- 遮罩库的快照只有 id 没有内容,它的查询事件只有哈希;运行之后才设的遮罩同样生效;
|
||||
- 附件名是「<运行名>-可验证报告.html」,中文名按 RFC 5987 编码;
|
||||
- 只读:导出前后运行记录、事件一条不多一条不少。
|
||||
|
||||
@@ -213,6 +213,39 @@ async def test_the_page_is_named_after_the_run(client, monkeypatch):
|
||||
assert f"filename*=UTF-8''{quote('门店周报-可验证报告.html', safe='')}" in header
|
||||
|
||||
|
||||
async def set_masks(name: str, columns: list[str]) -> None:
|
||||
"""只改数据源的遮罩列(options.mask_columns),别的不动。"""
|
||||
async with SessionLocal() as session:
|
||||
row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one()
|
||||
row.options = {**(row.options or {}), "mask_columns": columns}
|
||||
await session.commit()
|
||||
source_id = row.id
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
async def test_a_mask_set_after_the_run_still_applies(client, monkeypatch):
|
||||
"""运行之后才给数据源设遮罩:快照里没记 mask_columns,导出时照样按数据源现在的遮罩挡(和证据面板一个道理)。"""
|
||||
await set_masks("bundle_crm", [])
|
||||
row = await sealed_run(monkeypatch)
|
||||
shop = await query_artifact(row.id, "fetch")
|
||||
|
||||
def shown(bundle) -> dict[str, bool]:
|
||||
events = {(e["type"], e["node_id"]): "canonical" in e for e in bundle["events"]}
|
||||
return {"fetch": events[("tool.end", "fetch")], "members": events[("tool.end", "members")],
|
||||
"out": events[("node.finished", "out")], "finished": events[("run.finished", None)]}
|
||||
|
||||
# 对照:查询时、导出时都没有遮罩,快照随文件附带,成果和运行结束事件带规范字符串
|
||||
before = data_of((await client.get(f"/api/runs/{row.id}/bundle.html")).text)
|
||||
assert all("b64" in a for a in before["artifacts"])
|
||||
assert shown(before) == {"fetch": True, "members": True, "out": True, "finished": True}
|
||||
|
||||
await set_masks("bundle_shop", ["amount"])
|
||||
after = data_of((await client.get(f"/api/runs/{row.id}/bundle.html")).text)
|
||||
assert next(a for a in after["artifacts"] if a["id"] == shop) == {
|
||||
"id": shop, "kind": "query", "node_id": "fetch", "masked": True}
|
||||
assert shown(after) == {"fetch": False, "members": True, "out": False, "finished": False}
|
||||
|
||||
|
||||
async def test_report_text_cannot_close_the_data_block(client, monkeypatch):
|
||||
row = await sealed_run(monkeypatch, text="结论见下:</script><script>alert(1)</script> 流水 [[v:Q1.r0.amount]]。")
|
||||
html = (await client.get(f"/api/runs/{row.id}/bundle.html")).text
|
||||
@@ -289,8 +322,9 @@ async def test_a_broken_seal_is_refused(client, monkeypatch):
|
||||
await session.commit()
|
||||
resp = await client.get(f"/api/runs/{row.id}/bundle.html")
|
||||
assert resp.status_code == 409
|
||||
body = resp.json()
|
||||
assert body["code"] == "bundle_seal_broken" and "封存核对不一致" in body["detail"], body
|
||||
# 原样出现在记录页的错误提示里:一句说清,不叠冒号、不把封存核对的整句结论再拼进来
|
||||
assert resp.json() == {"detail": "封存核对不一致:封存后有事件被修改、删除或插入,不能导出可验证报告。"
|
||||
"点「核对」可查看核对结果", "code": "bundle_seal_broken"}
|
||||
|
||||
|
||||
async def test_an_unknown_run_is_404(client):
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
- 每条封存范围内的事件都带 data_sha256,按浏览器的拼法(seq|type|node_id|data_sha256 按 seq 排、换行连接)
|
||||
重算出来的清单哈希等于 runner 记下的那个;
|
||||
- 只有白名单里的事件带 canonical(逐字等于 canonical_json(data)),其余只给哈希;运行里有带遮罩的快照时,
|
||||
成果节点和运行结束事件的预览可能带出查询结果,也只给哈希;
|
||||
成果节点和运行结束事件的预览可能带出查询结果,也只给哈希。认不出快照的查询(升级前的运行从工具快照里认,
|
||||
协作团队成员的查询认不出)按带遮罩算,查询失败的原话不算;
|
||||
- 报告文档、它引用到的口径卡和查询快照按原字节嵌入;带遮罩的快照只给 id;没被引用的工件不嵌;
|
||||
- 封存之后追加的事件单列,不进清单;
|
||||
- 页面零外链,数据里的 </script> 截不断数据块。
|
||||
- 页面零外链,数据里的 </script> 截不断数据块;封存哈希、运行 ID、工作流名不执行脚本也写在页面上;
|
||||
- scripts/check-bundle.mjs 在浏览器里打开的样本页(tests/bundle_samples.py)封存哈希是真算的、形状如预期。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -198,6 +200,75 @@ def test_an_unreadable_snapshot_counts_as_masked():
|
||||
assert entry["missing"] is True and "b64" not in entry
|
||||
|
||||
|
||||
# 没有遮罩的报告、口径卡:成果和运行结束事件本该带规范字符串,下面几种查询让它们只给哈希
|
||||
PLAIN_DOC = {**doc_of(), "catalog": {k: v for k, v in doc_of()["catalog"].items() if k != "Q2"}}
|
||||
PLAIN_DOC_ID, PLAIN_DOC_RAW = stored(PLAIN_DOC)
|
||||
TOOL_SNAP_CRM = {"tool": "db_query__crm", "args": {"sql": SNAP_MASKED["sql"]},
|
||||
"result": json.dumps({**{k: v for k, v in SNAP_MASKED.items() if k != "mask_columns"},
|
||||
"artifact": MASKED_ID}, ensure_ascii=False)}
|
||||
TOOL_SNAP_CRM_ID, TOOL_SNAP_CRM_RAW = stored(TOOL_SNAP_CRM)
|
||||
|
||||
|
||||
def plain_events(members_end):
|
||||
"""报告只引用门店流水(没有遮罩)的运行;第 6 条是会员库那次查询,形状由调用方给。"""
|
||||
events = []
|
||||
for seq, etype, node_id, data in sample_events(PLAIN_DOC_ID):
|
||||
if seq == 6:
|
||||
data = members_end
|
||||
events.append((seq, etype, node_id, data))
|
||||
return events
|
||||
|
||||
|
||||
def shown_seqs(bundle) -> set[int]:
|
||||
return {e["seq"] for e in bundle["events"] if "canonical" in e}
|
||||
|
||||
|
||||
def test_a_run_without_masked_queries_shows_its_output():
|
||||
"""对照组:会员库那次查询有快照、没有遮罩,成果节点(14)和运行结束事件(15)带规范字符串。"""
|
||||
unmasked = {k: v for k, v in SNAP_MASKED.items() if k != "mask_columns"}
|
||||
plain_id, plain_raw = stored(unmasked)
|
||||
events = plain_events({"tool": "db_query__crm", "preview": "[[\"林一\"]]", "query_artifact": plain_id})
|
||||
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW, plain_id: plain_raw})
|
||||
assert {14, 15} <= shown_seqs(bundle)
|
||||
|
||||
|
||||
def test_a_pre_ledger_query_is_traced_through_its_tool_snapshot():
|
||||
"""升级前的运行:tool.end 只有工具快照(artifact),查询快照的 id 嵌在工具快照的返回里。照证据接口
|
||||
(_legacy_pool)的办法从工具快照认出查询快照:它带遮罩,成果和运行结束事件只给哈希。"""
|
||||
events = plain_events({"tool": "db_query__crm", "preview": "{\"rows\": [[\"林一\", \"000-0001\"…",
|
||||
"artifact": TOOL_SNAP_CRM_ID})
|
||||
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW, TOOL_SNAP_CRM_ID: TOOL_SNAP_CRM_RAW})
|
||||
assert not {6, 14, 15} & shown_seqs(bundle)
|
||||
assert "000-0001" not in json.dumps(bundle, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_a_query_that_cannot_be_traced_counts_as_masked():
|
||||
"""协作团队成员的查询:tool.end 既没有查询快照也没有工具快照,预览是截断的返回,认不出查的是什么。
|
||||
宁可多挡:按带遮罩算,成果和运行结束事件只给哈希。"""
|
||||
events = plain_events({"tool": "db_query__crm", "agent": "分析员", "call_id": "c1",
|
||||
"preview": "{\"columns\": [\"name\", \"phone\"], \"rows\": [[\"林一\", \"000-0001\"]"})
|
||||
events[13] = (14, "node.finished", "out", {"preview": {"明细": "林一 000-0001"}})
|
||||
events[14] = (15, "run.finished", None, {"output": {"明细": "林一 000-0001"}})
|
||||
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW})
|
||||
assert not {6, 14, 15} & shown_seqs(bundle)
|
||||
assert "000-0001" not in json.dumps(bundle, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_a_failed_query_returned_no_data():
|
||||
"""查询失败、SQL 被拒:返回的是一句原话,没有数据,不因此挡住成果。"""
|
||||
events = plain_events({"tool": "db_query__crm", "preview": "查询失败:no such table: members_x",
|
||||
"call_id": "c2"})
|
||||
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW})
|
||||
assert {14, 15} <= shown_seqs(bundle)
|
||||
|
||||
|
||||
def test_a_source_masked_now_hides_queries_without_a_snapshot():
|
||||
"""数据源现在设了遮罩:不必认出快照,按工具名就知道这次查询可能带着遮罩列。"""
|
||||
events = plain_events({"tool": "db_query__crm", "preview": "查询失败:timeout"})
|
||||
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW}, source_masks={"crm": ["phone"]})
|
||||
assert not {14, 15} & shown_seqs(bundle)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 工件:只嵌报告文档和它引用到的口径卡、查询快照
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -343,9 +414,85 @@ def test_the_verifier_script_is_inline_and_cannot_close_early():
|
||||
html = render_html(build())
|
||||
script = re.search(r"<script>(.*?)</script>", html, re.S).group(1)
|
||||
assert "</script" not in script.lower() and "<!--" not in script
|
||||
# 脚本在 <head> 里、排在数据块前面:它的注释里也不能出现「<script」,按标签找数据块的人才不会先找到它
|
||||
assert "<script" not in script.lower()
|
||||
assert html.index("<script>") < html.index('<script type="application/json" id="bundle-data">')
|
||||
assert "bundle-data" in script and "crypto.subtle" in script.replace("window.", "")
|
||||
|
||||
|
||||
def text_of(html: str, element_id: str) -> str:
|
||||
m = re.search(rf'<(\w+)[^>]*\sid="{element_id}"[^>]*>(.*?)</\1>', html, re.S)
|
||||
assert m, f"页面里没有 #{element_id}"
|
||||
return re.sub(r"<[^>]+>", "", m.group(2))
|
||||
|
||||
|
||||
def test_the_seal_and_run_are_written_without_script():
|
||||
"""附件预览、快速查看常常不执行脚本:封存哈希、运行 ID、工作流名直接写在页面上,状态说清还没核对。"""
|
||||
nasty = {**RUN, "id": "r1<b>", "workflow_name": "门店<i>周报</i>"}
|
||||
bundle = build_bundle(nasty, sample_events(), manifest_seq=15, manifest_hash="ab" * 32, graph=GRAPH,
|
||||
read=STORE.get, exported_at="2026-10-05T08:00:00+00:00")
|
||||
html = render_html(bundle)
|
||||
assert text_of(html, "seal-hash") == "ab" * 32
|
||||
assert "r1<b>" in text_of(html, "seal-note") and "<b>" not in text_of(html, "seal-note")
|
||||
assert text_of(html, "title") == "门店<i>周报</i>"
|
||||
# 没执行脚本时总览写的是「尚未核对」,不是一直转着的「正在核对」(那句只在脚本开始执行后才显示)
|
||||
assert re.search(r'class="no-js">尚未核对:[^<]*需要在浏览器中打开本文件', html)
|
||||
assert re.search(r'class="js-only">正在核对', html)
|
||||
# 成功结束的运行没有「继续运行会重新封存」那一句;失败的有
|
||||
assert re.search(r'id="seal-failed"[^>]*\shidden', html)
|
||||
failed = render_html(build_bundle({**RUN, "status": "failed"}, sample_events(), manifest_seq=15,
|
||||
manifest_hash="ab" * 32, graph=GRAPH, read=STORE.get, exported_at="x"))
|
||||
assert not re.search(r'id="seal-failed"[^>]*\shidden', failed)
|
||||
assert "重新封存" in text_of(failed, "seal-failed")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy", [True, False])
|
||||
def test_legacy_seals_are_flagged(legacy):
|
||||
assert build(legacy=legacy)["meta"]["legacy"] is legacy
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 浏览器检查用的样本页(tests/bundle_samples.py)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_sample_pages_for_the_browser_check_are_sealed_for_real():
|
||||
"""scripts/check-bundle.mjs 断网打开这些样本页,断言核对结果、再做篡改。pytest 不执行页面上的脚本,这里守住样本
|
||||
本身:封存哈希是对事件真算的(浏览器的拼法重算得出),各页的形状是 check-bundle 的断言预期的那样。"""
|
||||
from tests.bundle_samples import pages
|
||||
|
||||
got = {name: json.loads(data_block(page)) for name, page in pages().items()}
|
||||
assert set(got) == {"base", "two-reports", "legacy", "failed", "degraded", "missing-doc", "missing-card", "nan",
|
||||
"stray"}
|
||||
for name, bundle in got.items():
|
||||
assert browser_manifest(bundle["events"]) == bundle["meta"]["manifest_hash"], name
|
||||
|
||||
def segments(bundle, node_id="write"):
|
||||
doc_id = next(r["doc_artifact"] for r in bundle["reports"] if r["node_id"] == node_id)
|
||||
doc = json.loads(base64.b64decode(next(a["b64"] for a in bundle["artifacts"] if a["id"] == doc_id)))
|
||||
return [s for b in doc["blocks"] for u in b["units"] for s in u["segments"] if s["kind"] in ("number", "value")]
|
||||
|
||||
base = got["base"]
|
||||
shapes = {(a["kind"], a.get("node_id")): {k for k in a if k in ("b64", "masked", "missing")} for a in base["artifacts"]}
|
||||
assert shapes == {("report_doc", "write"): {"b64"}, ("metric_set", "card"): {"b64"}, ("query", "fetch"): {"b64"},
|
||||
("query", "members"): {"masked"}}
|
||||
cited = segments(base)
|
||||
# 数字与取值超过折叠的条数(20);没有出处的一个、引用遮罩快照的一个
|
||||
assert len(cited) == 21
|
||||
assert sum(1 for s in cited if not s.get("cite")) == 1 and sum(1 for s in cited if s["kind"] == "value") == 8
|
||||
assert [a["type"] for a in base["appended"]] == ["evidence.judged"]
|
||||
assert base["meta"]["tier"]["value"] == "formal" and base["meta"]["run_class"] == "formal"
|
||||
|
||||
assert [(a["kind"], a.get("missing")) for a in got["missing-doc"]["artifacts"]] == [("report_doc", True)]
|
||||
assert next(a for a in got["missing-card"]["artifacts"] if a["kind"] == "metric_set").get("missing") is True
|
||||
assert got["legacy"]["meta"]["legacy"] is True and "run.finished" in [a["type"] for a in got["legacy"]["appended"]]
|
||||
assert [r["node_id"] for r in got["two-reports"]["reports"]] == ["write", "monthly"] and segments(got["two-reports"], "monthly")
|
||||
failed = got["failed"]
|
||||
assert failed["meta"]["status"] == "failed" and failed["meta"]["tier"] is None
|
||||
assert max(failed["events"], key=lambda e: e["seq"])["type"] == "run.failed"
|
||||
issuances = [e["seq"] for e in got["degraded"]["events"] if e["type"] == "issuance"]
|
||||
assert len(issuances) == 2 and got["degraded"]["meta"]["tier"] == {"value": "degraded", "seq": issuances[-1]}
|
||||
assert [a["kind"] for a in got["stray"]["artifacts"]] == ["report_doc"]
|
||||
nan = got["nan"]
|
||||
assert "NaN" in next(e["canonical"] for e in nan["events"] if e["type"] == "run.started")
|
||||
assert b"NaN" in base64.b64decode(next(a["b64"] for a in nan["artifacts"] if a.get("node_id") == "fetch"))
|
||||
|
||||
@@ -10,7 +10,9 @@ import type { Run } from '../../types'
|
||||
* - bundle-provenance-sealed:已封存,可以导出
|
||||
* - bundle-provenance-running / -waiting:还在运行、等待审批,按钮不可用并说明原因
|
||||
* - bundle-provenance-unsealed:结束了却没有封存清单(加封存之前的老运行)
|
||||
* - bundle-provenance-again:失败后接着跑,留着上一次的封存,正在运行:同样不可用(跑完会换掉清单哈希)
|
||||
* - bundle-provenance-again:失败后接着跑,留着上一次的封存,正在运行:同样不可用(跑完会换掉清单哈希),左边写着
|
||||
* 「已封存」,原因写在屏幕上
|
||||
* - bundle-provenance-failed:失败结束、已封存:可以导出,提示里说明之后继续运行会重新封存
|
||||
*/
|
||||
const SEALED: Run = {
|
||||
id: 'f4b0f4b0f4b0f4b0f4b0f4b0f4b0f4b0',
|
||||
@@ -50,4 +52,5 @@ export const cases: Record<string, () => JSX.Element> = {
|
||||
'bundle-provenance-waiting': () => <Frame run={unsealed('interrupted')} phase="waiting" />,
|
||||
'bundle-provenance-unsealed': () => <Frame run={{ ...unsealed('succeeded'), finished_at: SEALED.finished_at }} phase="succeeded" />,
|
||||
'bundle-provenance-again': () => <Frame run={{ ...SEALED, status: 'running', finished_at: null }} phase="running" />,
|
||||
'bundle-provenance-failed': () => <Frame run={{ ...SEALED, status: 'failed' }} phase="failed" />,
|
||||
}
|
||||
|
||||
@@ -2756,11 +2756,14 @@ export const BUNDLE_TEXT = {
|
||||
title: '可验证报告',
|
||||
export: '导出可验证报告',
|
||||
hint: '导出为单个 HTML 文件:收件人不连服务,也能在浏览器中核对数字的出处和运行记录',
|
||||
/** 不能导出时按钮旁说明的原因:和凭证条左边「未封存 · …」的说法对得上 */
|
||||
/** 失败结束的运行:之后继续运行会重新封存,导出的文件就再也对不上系统里的封存了 */
|
||||
hintFailed: '导出为单个 HTML 文件。这次运行以失败结束:之后若继续运行,系统会重新封存,导出的文件将无法再与系统对照',
|
||||
/** 不能导出时说明的原因:和凭证条左边「未封存 · …」的说法对得上 */
|
||||
blockedRunning: '运行结束并封存后才能导出',
|
||||
blockedWaiting: '等待审批中,运行结束并封存后才能导出',
|
||||
blockedUnsealed: '这次运行没有封存清单,无法导出',
|
||||
exported: (name: string) => `已导出「${name}」`,
|
||||
exportedFailed: (name: string) => `已导出「${name}」。之后若继续运行,系统会重新封存,这份文件将无法再与系统对照`,
|
||||
/** 服务端没给文件名时按运行名补,规则同服务端的附件名 */
|
||||
fileName: (name: string) => `${name}-可验证报告.html`,
|
||||
}
|
||||
|
||||
@@ -291,13 +291,16 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount
|
||||
: !finished ? BUNDLE_TEXT.blockedRunning
|
||||
: BUNDLE_TEXT.blockedUnsealed
|
||||
|
||||
// 失败结束的运行可以「继续运行」:接着跑完会重新封存、换掉清单哈希,导出的文件从此对不上系统,导出前后都说一句
|
||||
const failedRun = phase === 'failed'
|
||||
|
||||
const doExport = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const { blob, name } = await api.bundle.download(run.id)
|
||||
const file = name ?? BUNDLE_TEXT.fileName(runName(run))
|
||||
saveFile(file, blob)
|
||||
toast.ok(BUNDLE_TEXT.exported(file))
|
||||
toast.ok(failedRun ? BUNDLE_TEXT.exportedFailed(file) : BUNDLE_TEXT.exported(file))
|
||||
} catch (e) {
|
||||
toast.error(e, { key: `bundle:${run.id}` })
|
||||
} finally {
|
||||
@@ -346,14 +349,20 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount
|
||||
</button>
|
||||
)}
|
||||
{/* 禁用的按钮收不到指针事件,悬停提示挂在外面那层上;读屏经 aria-describedby 读到原因 */}
|
||||
<span className="inline-flex" title={exportBlocked ?? BUNDLE_TEXT.hint}>
|
||||
<span className="inline-flex" title={exportBlocked ?? (failedRun ? BUNDLE_TEXT.hintFailed : BUNDLE_TEXT.hint)}>
|
||||
<button type="button" className="btn btn-xs btn-ghost" disabled={!!exportBlocked || exporting}
|
||||
onClick={() => void doExport()} aria-describedby={exportBlocked ? blockedId : undefined}
|
||||
data-bundle-export={exportBlocked ? 'blocked' : exporting ? 'busy' : 'ready'}>
|
||||
{exporting ? <Spinner size={10} /> : <Download size={11} aria-hidden />} {BUNDLE_TEXT.export}
|
||||
</button>
|
||||
</span>
|
||||
{exportBlocked && <span id={blockedId} className="sr-only" data-bundle-blocked="">{exportBlocked}</span>}
|
||||
{/* 没封存时左边写着「未封存 · …」,原因只给读屏和悬停;留着旧封存又在运行(失败后接着跑)时左边写着
|
||||
「已封存」,按钮却不能点,原因得写在屏幕上,触屏上看不到悬停提示 */}
|
||||
{exportBlocked && (
|
||||
<span id={blockedId} className={sealed ? 'text-faint' : 'sr-only'} data-bundle-blocked={sealed ? 'shown' : ''}>
|
||||
{exportBlocked}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-ghost"
|
||||
|
||||
@@ -96,7 +96,7 @@ const ESTIMATE = {
|
||||
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
|
||||
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
// 占位,换成真检查时按实测改
|
||||
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 10, whatif: 1,
|
||||
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 16, whatif: 1,
|
||||
}
|
||||
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
|
||||
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
|
||||
|
||||
+421
-88
@@ -1,32 +1,42 @@
|
||||
// 可验证报告的检查:真服务端导出、断网打开核对、篡改变红,外加记录页上的导出按钮。
|
||||
// 可验证报告的检查:真服务端导出、断网打开核对、篡改变红;虚构样本页上的各种形状;外加记录页上的导出按钮。
|
||||
//
|
||||
// 跑之前前后端都得起着(check-all 另起的那套,或者 ./scripts/dev.sh)。默认连 5273 / 8000;对别的实例跑时带上地址:
|
||||
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-bundle.mjs
|
||||
// 样本页用后端解释器生成(CHECK_PYTHON,默认 ~/miniforge3/envs/agentlab/bin/python),从这份工作区的 backend 导入。
|
||||
//
|
||||
// 分几段:
|
||||
// - 导出:从服务端找一份已封存、封存核对一致、有报告文档的运行(GET /api/runs 找成功结束的,再看 /verify 和证据接口),
|
||||
// 下载 bundle.html:状态码、类型、附件名(RFC 5987)、页面本身零外链。找不到可导出的运行时照实报失败并说明原因;
|
||||
// - 断网打开:拦掉一切网络请求,从本地文件打开:五项核对全部通过、总览通过、没有发出任何请求,数字点开看得到出处和核对结果;
|
||||
// - 断网打开:拦掉一切网络请求,从本地文件打开:各项核对没有不通过、总览通过、没有发出任何请求,数字点开看得到出处;
|
||||
// - 去掉 crypto.subtle:页面加载前删掉它,照常全部通过,页脚写明用的是本页内置的实现;
|
||||
// - 篡改:① 一条带规范字符串的事件内容、② 一份嵌入工件的一个字节、③ 元信息里的封存哈希,各自对应的核对项不通过、
|
||||
// 总览显示不通过;另做一次「哈希全部重算」的伪造:改报告里一个数字的引用值,文档、事件、清单的哈希都重算一遍,
|
||||
// ①–④ 都对得上,只有 ⑤ 拿口径卡、快照对出来不通过(这也是页面要人去系统里对照封存哈希的原因);
|
||||
// 总览显示不通过;另做一次「哈希全部重算」的伪造:改报告里一个数字的引用值,文档、事件、清单、页面顶部的封存哈希
|
||||
// 都重算一遍,①–④ 都对得上,只有 ⑤ 拿口径卡、快照对出来不通过,报告标题旁的徽标跟着不通过;
|
||||
// - 深浅色、窄屏:390 宽不横向溢出,点开的数字面板在窄屏上从底部抽出,深色下底是深的、字是浅的;
|
||||
// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、两条出具
|
||||
// 判定、服务端缺文件、裸 NaN、别的运行的文档),逐份断言各项结果和原因文案;在上面篡改 ④ 引用链(换文档、拿掉报告、
|
||||
// 多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把口径卡标成遮罩、只改页面顶部
|
||||
// 的封存哈希;宽屏点开表格里右对齐的数字不被面板盖住;不执行脚本时封存哈希、运行 ID 照样看得到,状态写「尚未核对」;
|
||||
// - 记录页的导出按钮(预览用例 ?syn=bundle-provenance-*,page.route 假装导出接口):已封存时可点、下载的文件名同服务端;
|
||||
// 未封存时不可点并说明原因;服务端回 409 时提示原因。
|
||||
// 未封存时不可点并说明原因(左边写着「已封存」却不能点时原因写在屏幕上);服务端回 409 时提示原因;失败结束的运行
|
||||
// 提示之后继续运行会重新封存。
|
||||
//
|
||||
// 读的是真数据:输出里不打印工作流名、报告内容和数字,只打印计数和运行号前 8 位。下载的文件写在临时目录,跑完删掉。
|
||||
// 导出接口只读(不追加事件、不改运行),页面检查里的写接口一律是假的。
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||||
|
||||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||||
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
|
||||
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||||
const BACKEND = join(dirname(dirname(fileURLToPath(import.meta.url))), 'backend')
|
||||
const PYTHON = [process.env.CHECK_PYTHON, join(homedir(), 'miniforge3/envs/agentlab/bin/python')]
|
||||
.find((p) => p && existsSync(p))
|
||||
|
||||
let failed = 0
|
||||
const check = (name, cond, detail = '') => {
|
||||
@@ -59,11 +69,15 @@ async function section(name, fn) {
|
||||
// ---------------------------------------------------------------- 页面数据的读写(篡改用)
|
||||
|
||||
const DATA_RE = /(<script type="application\/json" id="bundle-data">)([\s\S]*?)(<\/script>)/
|
||||
const SEAL_RE = /(<code id="seal-hash" class="hash">)([^<]*)(<\/code>)/
|
||||
const dataOf = (html) => JSON.parse(DATA_RE.exec(html)[2])
|
||||
// 和服务端 bundle_json 一样转义 < > & 和 U+2028 / U+2029,改过的数据照样截不断数据块
|
||||
const escapeJson = (obj) => JSON.stringify(obj)
|
||||
.replace(/[<>&\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
|
||||
const withData = (html, obj) => html.replace(DATA_RE, (_, open, __, close) => open + escapeJson(obj) + close)
|
||||
/** 只改页面顶部静态写出的封存哈希(不执行脚本也看得到的那一份) */
|
||||
const withSeal = (html, hash) => html.replace(SEAL_RE, (_, open, __, close) => open + hash + close)
|
||||
const sha = (data) => createHash('sha256').update(data).digest('hex')
|
||||
|
||||
function writePage(name, html) {
|
||||
const file = join(TMP, name)
|
||||
@@ -102,18 +116,81 @@ const stateOf = (page) => page.evaluate(() => ({
|
||||
overall: document.getElementById('overall')?.getAttribute('data-overall'),
|
||||
text: document.getElementById('overall-text')?.textContent ?? '',
|
||||
checks: Object.fromEntries([...document.querySelectorAll('[data-check]')].map((c) => [c.dataset.check, c.dataset.result])),
|
||||
sums: Object.fromEntries([...document.querySelectorAll('[data-check]')].map((c) => [c.dataset.check,
|
||||
c.querySelector('.check-sum')?.textContent ?? ''])),
|
||||
failsIn: Object.fromEntries([...document.querySelectorAll('[data-check]')].map((c) => [c.dataset.check,
|
||||
new Set([...c.querySelectorAll('li[data-result="fail"]')].map((li) => li.textContent)).size])),
|
||||
// 每一条核对:在哪一块、结果、文字(含原因)、是不是收在「展开全部」里
|
||||
items: [...document.querySelectorAll('[data-check] li[data-result]')].map((li) => ({
|
||||
check: li.closest('[data-check]').dataset.check, result: li.dataset.result, text: li.textContent,
|
||||
folded: !!li.closest('details'),
|
||||
})),
|
||||
reports: [...document.querySelectorAll('article.report')].map((a) => ({
|
||||
node: a.dataset.report, result: a.dataset.result, parts: a.querySelector('[data-report-parts]')?.textContent ?? '',
|
||||
warn: a.querySelector('.warn')?.textContent ?? '', why: a.querySelector('.warn')?.dataset.why ?? '',
|
||||
})),
|
||||
meta: Object.fromEntries([...document.querySelectorAll('[data-meta]')].map((m) => [m.dataset.meta,
|
||||
{ result: m.dataset.result, text: m.textContent }])),
|
||||
numbers: document.querySelectorAll('button.num').length,
|
||||
numberStates: [...document.querySelectorAll('button.num')].reduce((acc, b) => {
|
||||
acc[b.dataset.result] = (acc[b.dataset.result] ?? 0) + 1
|
||||
return acc
|
||||
}, {}),
|
||||
sha: document.querySelector('[data-sha]')?.dataset.sha ?? null,
|
||||
seal: document.getElementById('seal-hash')?.textContent ?? '',
|
||||
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
}))
|
||||
|
||||
const describe = (s) => Object.entries(s.checks).map(([k, v]) => `${k}=${v}`).join(' ')
|
||||
/** 有通过、没有不通过:「部分无法核对」也算(遮罩快照、没有出处的数字是正常的无法核对) */
|
||||
const noFail = (r) => r === 'pass' || r === 'partial'
|
||||
const hasItem = (s, check, result, text) => s.items.some((i) => i.check === check && i.result === result
|
||||
&& (!text || i.text.includes(text)))
|
||||
|
||||
/**
|
||||
* 「哈希全部重算」的伪造:改报告里一个能核对的数字的引用值,再把文档、引用它的事件、清单哈希、页面顶部的封存哈希
|
||||
* 全部重算。返回改了哪一处的说明,找不到可改的数字返回 null
|
||||
*/
|
||||
function forge(data) {
|
||||
const docArt = data.artifacts.find((a) => a.kind === 'report_doc' && typeof a.b64 === 'string')
|
||||
if (!docArt) return null
|
||||
const doc = JSON.parse(Buffer.from(docArt.b64, 'base64').toString('utf8'))
|
||||
const embedded = new Set(data.artifacts.filter((a) => typeof a.b64 === 'string').map((a) => a.id))
|
||||
let hit = null
|
||||
for (const block of doc.blocks ?? []) for (const unit of block.units ?? []) for (const seg of unit.segments ?? []) {
|
||||
const c = seg.cite
|
||||
if (!hit && c?.status === 'resolved' && ['metric', 'cell'].includes(c.kind) && typeof c.value === 'number'
|
||||
&& embedded.has(doc.catalog?.[c.alias]?.artifact)) hit = seg
|
||||
}
|
||||
if (!hit) return null
|
||||
hit.cite.value += 1
|
||||
const bytes = Buffer.from(JSON.stringify(doc), 'utf8')
|
||||
const oldId = docArt.id
|
||||
const newId = sha(bytes)
|
||||
docArt.id = newId
|
||||
docArt.b64 = bytes.toString('base64')
|
||||
for (const r of data.reports) if (r.doc_artifact === oldId) r.doc_artifact = newId
|
||||
for (const ev of data.events) {
|
||||
if (typeof ev.canonical === 'string' && ev.canonical.includes(oldId)) {
|
||||
ev.canonical = ev.canonical.split(oldId).join(newId)
|
||||
ev.data_sha256 = sha(ev.canonical)
|
||||
}
|
||||
}
|
||||
const lines = data.events.map((e, i) => [e, i]).sort((x, y) => x[0].seq - y[0].seq || x[1] - y[1])
|
||||
.map(([e]) => `${e.seq}|${e.type}|${e.node_id ?? ''}|${e.data_sha256}`)
|
||||
data.meta.manifest_hash = sha(lines.join('\n'))
|
||||
return { what: `片段 ${hit.id}(${hit.cite.kind})`, node: data.reports.find((r) => r.doc_artifact === newId)?.node_id }
|
||||
}
|
||||
|
||||
/** 改一份嵌入工件的一个字节(优先改数字),返回说明 */
|
||||
function flipByte(art) {
|
||||
const bytes = Buffer.from(art.b64, 'base64')
|
||||
const at = bytes.findIndex((b) => b >= 0x30 && b <= 0x39)
|
||||
const index = at >= 0 ? at : bytes.length - 2
|
||||
bytes[index] = at >= 0 ? (bytes[index] === 0x39 ? 0x30 : bytes[index] + 1) : bytes[index] ^ 0x01
|
||||
art.b64 = bytes.toString('base64')
|
||||
return `${art.kind} ${art.id.slice(0, 8)} 的第 ${index} 字节`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 导出
|
||||
|
||||
@@ -188,6 +265,7 @@ await section('导出', async () => {
|
||||
&& !/<script[^>]*\ssrc\s*=|<link\b|<img\b|@import|url\s*\(/i.test(shell))
|
||||
check('内容安全策略默认不许连任何地方', /default-src 'none'/.test(shell))
|
||||
check('数据块里没有裸的 < > &(截不断)', !/[<>&]/.test(DATA_RE.exec(html)?.[2] ?? '<'))
|
||||
check('封存哈希直接写在页面上(不执行脚本也看得到)', SEAL_RE.exec(html)?.[2] === run.manifest_hash)
|
||||
const after = await getJson(`/runs/${run.id}/events`).catch(() => null)
|
||||
check('导出不追加事件', !!before && !!after && before.length === after.length, before && after ? `${before.length} → ${after.length} 条` : '')
|
||||
// 没封存的,或者还没结束的(失败后接着跑、早年停在审批时封存过的运行留着旧的封存):都该回 409
|
||||
@@ -221,30 +299,33 @@ await section('断网打开', async () => {
|
||||
try {
|
||||
const s = await stateOf(page)
|
||||
check('总览通过', s.overall === 'pass', `${s.overall}:${s.text}`)
|
||||
for (const key of ['events', 'manifest', 'artifacts', 'chain']) {
|
||||
check(`核对项「${key}」通过`, s.checks[key] === 'pass', s.checks[key])
|
||||
}
|
||||
check('数字核对没有不通过的', s.checks.numbers === 'pass' || s.checks.numbers === 'na',
|
||||
for (const key of ['events', 'manifest', 'chain']) check(`核对项「${key}」通过`, s.checks[key] === 'pass', s.checks[key])
|
||||
// 遮罩快照、服务端已没有文件的工件是正常的无法核对;没有出处的数字同理
|
||||
check('核对项「artifacts」没有不通过', noFail(s.checks.artifacts), s.checks.artifacts)
|
||||
check('数字与取值没有不通过的', noFail(s.checks.numbers) || s.checks.numbers === 'na',
|
||||
`${s.checks.numbers}:${JSON.stringify(s.numberStates)}`)
|
||||
// 早期运行的运行结果、升级前的运行类别核对不了(无法核对),只要求没有对不上的
|
||||
check('运行信息没有与已核对的事件对不上的', s.checks.meta !== 'fail', `${s.checks.meta}:${JSON.stringify(s.meta)}`)
|
||||
check('没有发出任何网络请求', requests.length === 0, requests.slice(0, 3).join('、'))
|
||||
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||||
check('用的是浏览器内置的 SHA-256', s.sha === 'subtle', String(s.sha))
|
||||
check('封存哈希醒目地写在页面上', await page.locator('#seal-hash').innerText() === tamperable.meta.manifest_hash)
|
||||
check('封存哈希醒目地写在页面上', s.seal === tamperable.meta.manifest_hash)
|
||||
// 页面列出的清单原文:拿出去用别的工具(这里是 node 的 crypto)算,等于封存哈希——读的人可以不信页面上的脚本
|
||||
const lines = await page.locator('[data-manifest-lines]').textContent()
|
||||
check('页面列出的清单原文在页面之外重算,等于封存哈希',
|
||||
createHash('sha256').update(lines ?? '', 'utf8').digest('hex') === tamperable.meta.manifest_hash)
|
||||
check('页面写明只证明内部一致、真伪要去系统里对照', /内部一致/.test(await page.locator('#seal-note').innerText())
|
||||
&& /\/api\/runs\/[^/]+\/verify/.test(await page.locator('#seal-note').innerText()))
|
||||
check('页面列出的清单原文在页面之外重算,等于封存哈希', sha(lines ?? '') === tamperable.meta.manifest_hash)
|
||||
const sealNote = await page.locator('#seal-note').innerText()
|
||||
check('页面写明只证明内部一致、真伪要去系统里对照', /内部一致/.test(sealNote) && /\/api\/runs\/[^/]+\/verify/.test(sealNote))
|
||||
check('页面写明哪几项取自运行记录、不在封存范围内', /取自运行记录/.test(await page.locator('#record-note').innerText()))
|
||||
if (s.numbers) {
|
||||
const first = page.locator('button.num[data-result="pass"]').first()
|
||||
const first = page.locator('button.num[data-kind="number"][data-result="pass"]').first()
|
||||
const target = (await first.count()) ? first : page.locator('button.num').first()
|
||||
const key = await target.getAttribute('data-num')
|
||||
await target.click()
|
||||
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
|
||||
const text = await page.locator('#detail').innerText()
|
||||
check('点开数字看得到出处和核对结果', /出处/.test(text) && /核对结果/.test(text) && /显示格式由服务端渲染/.test(text)
|
||||
&& await page.locator('#detail').getAttribute('data-open') === key)
|
||||
const cited = await target.getAttribute('data-kind') === 'number'
|
||||
check('点开数字看得到出处和核对结果', /出处/.test(text) && /核对结果/.test(text)
|
||||
&& (!cited || /显示格式由服务端渲染/.test(text)) && await page.locator('#detail').getAttribute('data-open') === key)
|
||||
await page.keyboard.press('Escape')
|
||||
check('Esc 关掉出处面板', await page.locator('#detail').isHidden())
|
||||
} else {
|
||||
@@ -270,86 +351,63 @@ await section('去掉 crypto.subtle', async () => {
|
||||
|
||||
// ---------------------------------------------------------------- 篡改
|
||||
|
||||
async function tampered(label, mutate, expectFail, keepPass = []) {
|
||||
const data = structuredClone(tamperable)
|
||||
/**
|
||||
* 在一份页面上篡改后断网打开。mutate(data) 改数据、返回说明(找不到可改的返回 null);expect(state) 返回
|
||||
* [[检查名, 条件, 说明]…]。seal:同时把页面顶部的封存哈希换成改过的数据里的那一份(重算全部哈希的伪造会这么做)
|
||||
*/
|
||||
async function tamper(source, label, mutate, expect, { seal = false } = {}) {
|
||||
const data = structuredClone(dataOf(source))
|
||||
const what = mutate(data)
|
||||
if (!what) {
|
||||
check(`篡改:${label}`, false, '这份导出里找不到可以篡改的对象')
|
||||
check(`篡改:${label}`, false, '这份页面里找不到可以篡改的对象')
|
||||
return
|
||||
}
|
||||
const file = writePage(`tampered-${expectFail}.html`, withData(html, data))
|
||||
const { ctx, page } = await openFile(file)
|
||||
let page = withData(source, data)
|
||||
if (seal) page = withSeal(page, data.meta.manifest_hash)
|
||||
const opened = await openFile(writePage(`tampered-${label.replace(/[^\w一-鿿]+/g, '_')}.html`, page))
|
||||
try {
|
||||
const s = await stateOf(page)
|
||||
check(`篡改${label}:「${expectFail}」不通过、总览不通过`, s.checks[expectFail] === 'fail' && s.overall === 'fail'
|
||||
&& s.failsIn[expectFail] >= 1, `${what};${describe(s)}`)
|
||||
for (const key of keepPass) check(`篡改${label}:「${key}」仍通过`, s.checks[key] === 'pass', s.checks[key])
|
||||
const s = await stateOf(opened.page)
|
||||
for (const [name, cond, detail] of expect(s, what)) check(`篡改${label}:${name}`, cond, detail ?? `${typeof what === 'string' ? what : what.what};${describe(s)}`)
|
||||
} finally {
|
||||
await ctx.close()
|
||||
await opened.ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** 一项变红、总览不通过,另几项仍通过 */
|
||||
const turnsRed = (key, keepPass = []) => (s) => [
|
||||
[`「${key}」不通过、总览不通过`, s.checks[key] === 'fail' && s.overall === 'fail' && s.failsIn[key] >= 1],
|
||||
...keepPass.map((k) => [`「${k}」仍没有不通过`, noFail(s.checks[k]), s.checks[k]]),
|
||||
]
|
||||
|
||||
await section('篡改', async () => {
|
||||
if (skipped() || !tamperable) return
|
||||
// ① 改一条带规范字符串的事件内容(不动它记的哈希):只有 ① 变红,清单照样对得上
|
||||
await tampered('一条事件的内容', (data) => {
|
||||
await tamper(html, '一条事件的内容', (data) => {
|
||||
const ev = data.events.find((e) => e.type === 'run.started' && typeof e.canonical === 'string')
|
||||
?? data.events.find((e) => typeof e.canonical === 'string')
|
||||
if (!ev) return null
|
||||
ev.canonical = /\d/.test(ev.canonical) ? ev.canonical.replace(/\d/, (d) => String((Number(d) + 1) % 10)) : `${ev.canonical} `
|
||||
return `第 ${ev.seq} 条(${ev.type})`
|
||||
}, 'events', ['manifest'])
|
||||
}, turnsRed('events', ['manifest']))
|
||||
// ② 改一份嵌入工件的一个字节:优先口径卡、快照,没有就改报告文档
|
||||
await tampered('一份工件的一个字节', (data) => {
|
||||
await tamper(html, '一份工件的一个字节', (data) => {
|
||||
const embedded = data.artifacts.filter((a) => typeof a.b64 === 'string')
|
||||
const art = embedded.find((a) => a.kind !== 'report_doc') ?? embedded[0]
|
||||
if (!art) return null
|
||||
const bytes = Buffer.from(art.b64, 'base64')
|
||||
const at = bytes.findIndex((b) => b >= 0x30 && b <= 0x39)
|
||||
const index = at >= 0 ? at : bytes.length - 2
|
||||
bytes[index] = at >= 0 ? (bytes[index] === 0x39 ? 0x30 : bytes[index] + 1) : bytes[index] ^ 0x01
|
||||
art.b64 = bytes.toString('base64')
|
||||
return `${art.kind} ${art.id.slice(0, 8)} 的第 ${index} 字节`
|
||||
}, 'artifacts', ['events', 'manifest'])
|
||||
// 伪造:改报告里一个数字的引用值,再把文档、引用它的事件、清单的哈希全部重算
|
||||
await tampered('一个数字的引用值并重算全部哈希', (data) => {
|
||||
const sha = (text) => createHash('sha256').update(text).digest('hex')
|
||||
const docArt = data.artifacts.find((a) => a.kind === 'report_doc' && typeof a.b64 === 'string')
|
||||
if (!docArt) return null
|
||||
const doc = JSON.parse(Buffer.from(docArt.b64, 'base64').toString('utf8'))
|
||||
const embedded = new Set(data.artifacts.filter((a) => typeof a.b64 === 'string').map((a) => a.id))
|
||||
let hit = null
|
||||
for (const block of doc.blocks ?? []) for (const unit of block.units ?? []) for (const seg of unit.segments ?? []) {
|
||||
const c = seg.cite
|
||||
if (!hit && c?.status === 'resolved' && ['metric', 'cell'].includes(c.kind) && typeof c.value === 'number'
|
||||
&& embedded.has(doc.catalog?.[c.alias]?.artifact)) hit = seg
|
||||
}
|
||||
if (!hit) return null
|
||||
hit.cite.value += 1
|
||||
const bytes = Buffer.from(JSON.stringify(doc), 'utf8')
|
||||
const oldId = docArt.id
|
||||
const newId = sha(bytes)
|
||||
docArt.id = newId
|
||||
docArt.b64 = bytes.toString('base64')
|
||||
for (const r of data.reports) if (r.doc_artifact === oldId) r.doc_artifact = newId
|
||||
for (const ev of data.events) {
|
||||
if (typeof ev.canonical === 'string' && ev.canonical.includes(oldId)) {
|
||||
ev.canonical = ev.canonical.split(oldId).join(newId)
|
||||
ev.data_sha256 = sha(ev.canonical)
|
||||
}
|
||||
}
|
||||
const lines = data.events.map((e, i) => [e, i]).sort((x, y) => x[0].seq - y[0].seq || x[1] - y[1])
|
||||
.map(([e]) => `${e.seq}|${e.type}|${e.node_id ?? ''}|${e.data_sha256}`)
|
||||
data.meta.manifest_hash = sha(lines.join('\n'))
|
||||
return `片段 ${hit.id}(${hit.cite.kind})`
|
||||
}, 'numbers', ['events', 'manifest', 'artifacts', 'chain'])
|
||||
return art ? flipByte(art) : null
|
||||
}, turnsRed('artifacts', ['events', 'manifest']))
|
||||
// 伪造:改报告里一个数字的引用值,再把文档、引用它的事件、清单、页面顶部的封存哈希全部重算
|
||||
await tamper(html, '一个数字的引用值并重算全部哈希', forge, (s, forged) => [
|
||||
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain'])(s),
|
||||
['那份报告标题旁的徽标跟着不通过', s.reports.find((r) => r.node === forged.node)?.result === 'fail',
|
||||
JSON.stringify(s.reports.map((r) => [r.node, r.result]))],
|
||||
], { seal: true })
|
||||
// ③ 改元信息里的封存哈希
|
||||
await tampered('元信息里的封存哈希', (data) => {
|
||||
await tamper(html, '元信息里的封存哈希', (data) => {
|
||||
const h = data.meta.manifest_hash
|
||||
if (typeof h !== 'string' || !h) return null
|
||||
data.meta.manifest_hash = h.slice(0, -1) + (h.endsWith('0') ? '1' : '0')
|
||||
return '最后一位'
|
||||
}, 'manifest', ['events', 'artifacts'])
|
||||
}, turnsRed('manifest', ['events', 'artifacts']))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 深浅色、窄屏
|
||||
@@ -391,9 +449,263 @@ await section('深浅色与窄屏', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 样本页
|
||||
|
||||
/** tests/bundle_samples.py 生成的页面:{名字: HTML}。生成不了时抛出,原因写清 */
|
||||
function samplePages() {
|
||||
if (!PYTHON) throw new Error('找不到后端解释器,用 CHECK_PYTHON 指定')
|
||||
const out = join(TMP, 'samples')
|
||||
const r = spawnSync(PYTHON, ['-m', 'tests.bundle_samples', out], {
|
||||
cwd: BACKEND, encoding: 'utf8', timeout: 120_000,
|
||||
// 从这份工作区的 backend 导入;导入时会建数据目录,指到临时目录里
|
||||
env: { ...process.env, PYTHONPATH: BACKEND, AGENTLAB_DATA_DIR: join(TMP, 'py-data'), AGENTLAB_SECRET_KEY: 'check-bundle-samples' },
|
||||
})
|
||||
if (r.status !== 0) throw new Error(`生成样本页失败:${(r.stderr || String(r.error ?? '')).trim().split('\n').pop()}`)
|
||||
return Object.fromEntries(readdirSync(out).filter((f) => f.endsWith('.html'))
|
||||
.map((f) => [f.replace(/\.html$/, ''), readFileSync(join(out, f), 'utf8')]))
|
||||
}
|
||||
|
||||
let samples = null
|
||||
|
||||
/** 断网打开一份样本页,交给 fn(state, page) 断言 */
|
||||
async function sample(name, fn, opts = {}) {
|
||||
const { ctx, page, requests, errors } = await openFile(writePage(`sample-${name}.html`, samples[name]), opts)
|
||||
try {
|
||||
const s = await stateOf(page)
|
||||
if (requests.length || errors.length) check(`${name}:没有发出请求、没有报错`, false, [...requests, ...errors].slice(0, 2).join(' | '))
|
||||
await fn(s, page)
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
await section('样本页:各种形状', async () => {
|
||||
try {
|
||||
samples = samplePages()
|
||||
} catch (e) {
|
||||
check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e))
|
||||
return
|
||||
}
|
||||
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 9, Object.keys(samples).join('、'))
|
||||
|
||||
await sample('base', async (s, page) => {
|
||||
check('base:总览通过,有无法核对的项时写「可核对的项全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过',
|
||||
`${s.overall}:${s.text}`)
|
||||
check('base:①②④⑥ 通过,③⑤ 部分无法核对', ['events', 'manifest', 'chain', 'meta'].every((k) => s.checks[k] === 'pass')
|
||||
&& s.checks.artifacts === 'partial' && s.checks.numbers === 'partial', describe(s))
|
||||
check('base:遮罩快照在 ③ 无法核对,写明「含遮罩列,未随文件附带,只能核对到哈希」',
|
||||
hasItem(s, 'artifacts', 'na', '含遮罩列,未随文件附带,只能核对到哈希'))
|
||||
check('base:引用遮罩快照的数字无法核对,原因同上', hasItem(s, 'numbers', 'na', '含遮罩列,未随文件附带,只能核对到哈希'))
|
||||
check('base:没有出处的数字写明未使用引用标记', hasItem(s, 'numbers', 'na', '该数字未使用引用标记,无法核对其来源'))
|
||||
const outside = s.items.filter((i) => i.check === 'numbers' && !i.folded)
|
||||
check('base:⑤ 超过 20 项收起来时,无法核对的列在外面', outside.length === 2 && outside.every((i) => i.result === 'na')
|
||||
&& s.items.some((i) => i.check === 'numbers' && i.folded), `外面 ${outside.length} 条`)
|
||||
check('base:⑤ 分开数数字和取值,写明分项', s.sums.numbers.includes('数字 13 个 · 取值 8 个') && s.sums.numbers.includes('无法核对 2'),
|
||||
s.sums.numbers)
|
||||
check('base:报告徽标连同数字一起算(部分无法核对),旁边写分项', s.reports[0]?.result === 'partial'
|
||||
&& s.reports[0].parts.includes('文档 通过') && s.reports[0].parts.includes('无法核对 2'), JSON.stringify(s.reports[0]))
|
||||
check('base:运行结果、类别、出具档位标着已核对', ['status', 'class', 'tier'].every((k) => s.meta[k]?.result === 'pass'),
|
||||
JSON.stringify(s.meta))
|
||||
check('base:封存之后追加的事件单列,标明不在封存范围内',
|
||||
(await page.locator('[data-appended]').allTextContents()).some((t) => t.includes('封存之后追加,不在封存范围内')))
|
||||
check('base:斜体渲染成斜体', (await page.locator('.doc em').allTextContents()).includes('按发生周统计'))
|
||||
const cell = page.locator('td.n button.num')
|
||||
check('base:表格里数字列右对齐', await cell.count() === 8)
|
||||
// 「显示格式由服务端渲染」只附在带出处的数字上:模型自己写的数、表格里的文字取值都是原文
|
||||
const detailOf = async (locator) => {
|
||||
await locator.click()
|
||||
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
|
||||
const text = await page.locator('#detail').innerText()
|
||||
await page.keyboard.press('Escape')
|
||||
return text
|
||||
}
|
||||
const cellNumber = page.locator('td.n button.num[data-kind="number"]').first()
|
||||
const value = page.locator('button.num[data-kind="value"]').first()
|
||||
const bare = page.locator('button.num[data-kind="number"][data-result="na"]').filter({ hasText: /^42$/ })
|
||||
check('base:带出处的数字注明显示格式由服务端渲染', /显示格式由服务端渲染/.test(await detailOf(cellNumber)))
|
||||
check('base:文字取值、没有出处的数字不注明显示格式', !/显示格式由服务端渲染/.test(await detailOf(value))
|
||||
&& !/显示格式由服务端渲染/.test(await detailOf(bare)))
|
||||
check('base:读屏把文字取值读作「取值」、数字读作「数字」', (await value.getAttribute('aria-label')).startsWith('取值 ')
|
||||
&& (await cellNumber.getAttribute('aria-label')).startsWith('数字 '))
|
||||
})
|
||||
|
||||
// 宽屏:出处面板停在右侧,正文让出这一栏。点开表格里右对齐的数字,它和同列的数字都不被面板盖住
|
||||
for (const width of [1280, 1440]) {
|
||||
await sample('base', async (s, page) => {
|
||||
const cells = page.locator('td.n button.num')
|
||||
await cells.nth(2).click()
|
||||
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
|
||||
const covered = await cells.nth(2).evaluate((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const at = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||||
return !!at && !!at.closest('#detail')
|
||||
})
|
||||
const panel = await page.locator('#detail').boundingBox()
|
||||
const spot = await cells.nth(2).boundingBox()
|
||||
check(`${width} 宽:点开的数字不被出处面板盖住`, !covered && !!panel && !!spot && spot.x + spot.width <= panel.x,
|
||||
spot && panel ? `数字 ${Math.round(spot.x)}–${Math.round(spot.x + spot.width)},面板从 ${Math.round(panel.x)} 起` : '')
|
||||
const next = await cells.nth(4).click({ timeout: 3000 }).then(() => true, () => false)
|
||||
check(`${width} 宽:面板开着时同列别的数字照样点得到`, next
|
||||
&& await page.locator('#detail').getAttribute('data-open') === await cells.nth(4).getAttribute('data-num'))
|
||||
check(`${width} 宽:不横向溢出`, await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
|
||||
}, { width })
|
||||
}
|
||||
|
||||
await sample('two-reports', async (s) => {
|
||||
check('two-reports:两份报告各自显示,引用链都通过', s.reports.length === 2 && s.checks.chain === 'pass'
|
||||
&& s.items.filter((i) => i.check === 'chain' && i.text.includes('的文档')).length === 2, JSON.stringify(s.reports.map((r) => r.node)))
|
||||
check('two-reports:月报的数字全部通过', s.reports.find((r) => r.node === 'monthly')?.result === 'pass')
|
||||
})
|
||||
await sample('legacy', async (s, page) => {
|
||||
check('legacy:总览通过,范围写明按结束事件推算', s.overall === 'pass'
|
||||
&& (await page.locator('#meta').innerText()).includes('早期运行,范围按结束事件推算'), s.text)
|
||||
check('legacy:运行结果无法核对,写明早期运行的结束事件不在封存范围内', s.meta.status?.result === 'na'
|
||||
&& hasItem(s, 'meta', 'na', '早期运行在结束事件落库之前封存'), JSON.stringify(s.meta.status))
|
||||
check('legacy:运行结束事件列在封存之后', (await page.locator('[data-appended]').allTextContents()).some((t) => t.includes('运行结束')))
|
||||
})
|
||||
await sample('failed', async (s, page) => {
|
||||
check('failed:运行结果「已失败」与运行失败事件对上', s.meta.status?.result === 'pass' && s.meta.status.text.includes('已失败'))
|
||||
check('failed:封存说明写明之后继续运行会重新封存', await page.locator('#seal-failed').isVisible()
|
||||
&& (await page.locator('#seal-failed').innerText()).includes('重新封存'))
|
||||
})
|
||||
await sample('base', async (_, page) => {
|
||||
check('成功结束的运行不提重新封存', await page.locator('#seal-failed').isHidden())
|
||||
})
|
||||
await sample('degraded', async (s) => {
|
||||
check('degraded:出具档位认最后一条出具判定(降档出具)', s.meta.tier?.result === 'pass' && s.meta.tier.text.includes('降档出具'),
|
||||
JSON.stringify(s.meta.tier))
|
||||
})
|
||||
await sample('missing-doc', async (s, page) => {
|
||||
check('missing-doc:总览不是绿色的通过,写明有报告未能核对', s.overall === 'incomplete' && s.text.includes('份报告未能核对'),
|
||||
`${s.overall}:${s.text}`)
|
||||
check('missing-doc:报告处写明导出时服务端已没有这份报告文档', s.reports[0]?.why === 'missing'
|
||||
&& s.reports[0].warn.includes('导出时服务端已没有这份报告文档'), s.reports[0]?.warn)
|
||||
check('missing-doc:③ ④ 无法核对,原因写明', hasItem(s, 'artifacts', 'na', '导出时服务端已没有这份工件的文件')
|
||||
&& hasItem(s, 'chain', 'na', '导出时服务端已没有这份报告文档'))
|
||||
check('missing-doc:总览框不是通过的绿底', await page.locator('#overall').evaluate((el) => {
|
||||
const probe = document.body.appendChild(document.createElement('div'))
|
||||
probe.style.background = 'var(--ok-soft)'
|
||||
const ok = getComputedStyle(probe).backgroundColor
|
||||
probe.remove()
|
||||
return getComputedStyle(el).backgroundColor !== ok
|
||||
}))
|
||||
})
|
||||
await sample('missing-card', async (s) => {
|
||||
check('missing-card:引用口径卡的数字无法核对,写明服务端已没有文件', hasItem(s, 'numbers', 'na', '导出时服务端已没有这份工件的文件'))
|
||||
check('missing-card:服务端缺文件是正常的无法核对,总览通过', s.overall === 'pass', `${s.overall}:${s.text}`)
|
||||
})
|
||||
await sample('nan', async (s) => {
|
||||
check('nan:内容里有裸 NaN 的事件和工件照常通过', s.overall === 'pass' && s.checks.events === 'pass'
|
||||
&& !s.items.some((i) => i.result === 'fail'), describe(s))
|
||||
})
|
||||
await sample('stray', async (s) => {
|
||||
check('stray:另一次运行的文档,④ 不通过并写明', s.overall === 'fail' && hasItem(s, 'chain', 'fail', '文档记下的运行或节点与这次不符'))
|
||||
check('stray:文档没通过核对时数字不予核对', s.checks.numbers === 'na' && hasItem(s, 'numbers', 'na', '所在的报告文档没有通过核对')
|
||||
&& s.reports[0]?.warn.includes('其中的数字不予核对'), s.checks.numbers)
|
||||
})
|
||||
|
||||
// 不执行脚本(附件预览、快速查看):封存哈希、运行 ID、工作流名照样看得到,状态写「尚未核对」,不是一直「正在核对」
|
||||
{
|
||||
const data = dataOf(samples.base)
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, javaScriptEnabled: false })
|
||||
try {
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(pathToFileURL(writePage('sample-nojs.html', samples.base)).href)
|
||||
check('不执行脚本:封存哈希写在页面上', await page.locator('#seal-hash').innerText() === data.meta.manifest_hash)
|
||||
check('不执行脚本:运行 ID、工作流名写在页面上', (await page.locator('#seal-note').innerText()).includes(data.meta.run_id)
|
||||
&& await page.locator('#title').innerText() === data.meta.workflow_name)
|
||||
check('不执行脚本:状态写「尚未核对」,不显示「正在核对」', await page.locator('.no-js').isVisible()
|
||||
&& (await page.locator('#overall').innerText()).includes('尚未核对') && !(await page.locator('#overall').innerText()).includes('正在核对'))
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await section('样本页:篡改', async () => {
|
||||
if (!samples) {
|
||||
note('没有样本页(原因见上一节),这一节跳过')
|
||||
return
|
||||
}
|
||||
const base = samples.base
|
||||
const byKind = (data, kind) => data.artifacts.find((a) => a.kind === kind)
|
||||
// ④ 引用链
|
||||
await tamper(base, '报告记录指向另一份工件', (data) => {
|
||||
data.reports[0].doc_artifact = byKind(data, 'metric_set').id
|
||||
return '报告的文档换成口径卡'
|
||||
}, (s) => [...turnsRed('chain')(s), ['原因写明', hasItem(s, 'chain', 'fail', '这个节点最后一次报告自查记下的不是这份文档')]])
|
||||
await tamper(base, '拿掉报告记录', (data) => {
|
||||
data.reports = []
|
||||
return '清空 reports'
|
||||
}, (s) => [...turnsRed('chain')(s), ['原因写明', hasItem(s, 'chain', 'fail', '运行记录里有这个报告节点的自查事件,文件里却没有附带它的文档')]])
|
||||
await tamper(base, '多一份没人引用的工件', (data) => {
|
||||
const bytes = Buffer.from(JSON.stringify({ columns: ['n'], rows: [[7]], source: 'shop', sql: 'SELECT 7 AS n' }), 'utf8')
|
||||
data.artifacts.push({ id: sha(bytes), kind: 'query', node_id: 'fetch', b64: bytes.toString('base64') })
|
||||
return '追加一份哈希正确的快照'
|
||||
}, (s) => [...turnsRed('chain', ['artifacts'])(s), ['原因写明', hasItem(s, 'chain', 'fail', '已核对的事件和报告文档里都没有引用这份工件')]])
|
||||
// 服务端不会给出的状态:只能是改过
|
||||
await tamper(base, '拿掉被引用的口径卡', (data) => {
|
||||
data.artifacts = data.artifacts.filter((a) => a.kind !== 'metric_set')
|
||||
return '删掉口径卡条目'
|
||||
}, (s) => [...turnsRed('numbers')(s), ['原因写明', hasItem(s, 'numbers', 'fail', '引用的工件不在文件的工件清单里')]])
|
||||
await tamper(base, '口径卡标成遮罩', (data) => {
|
||||
const card = byKind(data, 'metric_set')
|
||||
delete card.b64
|
||||
card.masked = true
|
||||
return '口径卡去掉内容、标遮罩'
|
||||
}, (s) => [...turnsRed('artifacts')(s), ['原因写明', hasItem(s, 'artifacts', 'fail', '只有查询快照会因遮罩不随文件附带')]])
|
||||
await tamper(base, '报告文档的一个字节', (data) => flipByte(byKind(data, 'report_doc')), (s) => [
|
||||
...turnsRed('artifacts')(s),
|
||||
['数字不予核对', s.checks.numbers === 'na' && hasItem(s, 'numbers', 'na', '所在的报告文档没有通过核对')],
|
||||
['报告处写明没有通过核对', s.reports[0]?.result === 'fail' && s.reports[0].why === 'untrusted'],
|
||||
])
|
||||
// ⑥ 运行信息:改元信息不动事件,清单照样对得上,只有 ⑥ 变红,页面上显示的是事件里的值
|
||||
await tamper(base, '运行结果', (data) => {
|
||||
data.meta.status = 'failed'
|
||||
return 'succeeded → failed'
|
||||
}, (s) => [...turnsRed('meta', ['events', 'manifest'])(s),
|
||||
['页面显示事件里的「已完成」并标明文件里写的不同', s.meta.status?.result === 'fail' && s.meta.status.text.includes('已完成')
|
||||
&& s.meta.status.text.includes('已失败'), JSON.stringify(s.meta.status)]])
|
||||
await tamper(base, '运行类别', (data) => {
|
||||
data.meta.run_class = 'exploratory'
|
||||
return 'formal → exploratory'
|
||||
}, (s) => [...turnsRed('meta')(s), ['原因写明', hasItem(s, 'meta', 'fail', '记下的运行参数不符')]])
|
||||
await tamper(base, '去掉出具档位', (data) => {
|
||||
data.meta.tier = null
|
||||
return 'tier → null'
|
||||
}, (s) => [...turnsRed('meta')(s), ['页面显示事件里的档位', s.meta.tier?.result === 'fail' && s.meta.tier.text.includes('完整出具')]])
|
||||
await tamper(samples.degraded, '出具档位改指早先那条', (data) => {
|
||||
const first = data.events.find((e) => e.type === 'issuance')
|
||||
data.meta.tier = { value: 'formal', seq: first.seq }
|
||||
return `指向第 ${first.seq} 条(完整出具)`
|
||||
}, (s) => [...turnsRed('meta')(s),
|
||||
['页面显示最后一条的降档出具', s.meta.tier?.result === 'fail' && s.meta.tier.text.includes('降档出具'), JSON.stringify(s.meta.tier)]])
|
||||
// 只改页面顶部静态写出的封存哈希:数据里的没动,② 的清单照样对得上,但顶上那份对不上,核对后换成核对用的
|
||||
{
|
||||
const data = dataOf(base)
|
||||
const fake = data.meta.manifest_hash.replace(/^./, (c) => (c === '0' ? '1' : '0'))
|
||||
const opened = await openFile(writePage('tampered-seal.html', withSeal(base, fake)))
|
||||
try {
|
||||
const s = await stateOf(opened.page)
|
||||
check('篡改页面顶部的封存哈希:② 不通过、总览不通过', s.checks.manifest === 'fail' && s.overall === 'fail'
|
||||
&& hasItem(s, 'manifest', 'fail', '页面被改过'), describe(s))
|
||||
check('篡改页面顶部的封存哈希:核对后顶上显示核对用的那一份', s.seal === data.meta.manifest_hash)
|
||||
} finally {
|
||||
await opened.ctx.close()
|
||||
}
|
||||
}
|
||||
// 重算全部哈希的伪造在样本页上同样露馅:⑤ 不通过,报告徽标跟着不通过
|
||||
await tamper(base, '样本页上重算全部哈希的伪造', forge, (s) => [
|
||||
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain', 'meta'])(s),
|
||||
['报告徽标不通过', s.reports[0]?.result === 'fail' && s.reports[0].parts.includes('不通过 1'), JSON.stringify(s.reports[0])],
|
||||
], { seal: true })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 记录页的导出按钮
|
||||
|
||||
const SAMPLE_NAME = '门店周报-可验证报告.html'
|
||||
// 和服务端 api/bundle.py 的 SEAL_BROKEN 一字不差(服务端单测断言那一边):提示里原样显示
|
||||
const SEAL_BROKEN = '封存核对不一致:封存后有事件被修改、删除或插入,不能导出可验证报告。点「核对」可查看核对结果'
|
||||
|
||||
async function preview(syn, { width = 1280, bundle } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width, height: 700 }, acceptDownloads: true })
|
||||
@@ -436,41 +748,62 @@ await section('记录页的导出按钮', async () => {
|
||||
}
|
||||
}
|
||||
{
|
||||
const refusal = { status: 409, contentType: 'application/json',
|
||||
body: JSON.stringify({ detail: '封存核对不一致:事件记录与封存时不一致', code: 'bundle_seal_broken' }) }
|
||||
const refusal = { status: 409, contentType: 'application/json', body: JSON.stringify({ detail: SEAL_BROKEN, code: 'bundle_seal_broken' }) }
|
||||
const { ctx, page } = await preview('bundle-provenance-sealed', { bundle: refusal })
|
||||
try {
|
||||
await page.locator('[data-bundle-export]').click()
|
||||
check('服务端回 409 时提示原因', await appears(page.getByText('封存核对不一致:事件记录与封存时不一致')))
|
||||
check('服务端回 409 时原样提示原因', await appears(page.getByText(SEAL_BROKEN)))
|
||||
check('提示之后按钮恢复可点', await appears(page.locator('[data-bundle-export="ready"]')))
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
for (const [syn, reason] of [['bundle-provenance-running', '运行结束并封存后才能导出'],
|
||||
['bundle-provenance-waiting', '等待审批中'], ['bundle-provenance-unsealed', '没有封存清单'],
|
||||
['bundle-provenance-again', '运行结束并封存后才能导出']]) {
|
||||
{
|
||||
// 失败结束的运行:导出前后都说明之后继续运行会重新封存
|
||||
const { ctx, page } = await preview('bundle-provenance-failed')
|
||||
try {
|
||||
const button = page.locator('[data-bundle-export]')
|
||||
const hover = await button.locator('xpath=..').getAttribute('title')
|
||||
check('失败结束:可以导出,悬停提示写明继续运行会重新封存', await button.isEnabled() && (hover ?? '').includes('重新封存'), hover ?? '')
|
||||
const [download] = await Promise.all([page.waitForEvent('download', { timeout: 10_000 }), button.click()])
|
||||
check('失败结束:导出后的提示写明继续运行会重新封存', !!download
|
||||
&& await appears(page.getByText(`已导出「${SAMPLE_NAME}」。之后若继续运行,系统会重新封存`, { exact: false })))
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
for (const [syn, reason, shown] of [['bundle-provenance-running', '运行结束并封存后才能导出', false],
|
||||
['bundle-provenance-waiting', '等待审批中', false], ['bundle-provenance-unsealed', '没有封存清单', false],
|
||||
['bundle-provenance-again', '运行结束并封存后才能导出', true]]) {
|
||||
const { ctx, page, hits } = await preview(syn)
|
||||
const label = syn.replace('bundle-provenance-', '')
|
||||
try {
|
||||
const button = page.locator('[data-bundle-export]')
|
||||
const described = await button.getAttribute('aria-describedby')
|
||||
const spoken = described ? await page.locator(`[id="${described}"]`).innerText() : ''
|
||||
const hover = await page.locator('[data-bundle-export]').locator('xpath=..').getAttribute('title')
|
||||
check(`${syn.replace('bundle-provenance-', '')}:按钮不可点,说明原因`, await button.isDisabled()
|
||||
check(`${label}:按钮不可点,说明原因`, await button.isDisabled()
|
||||
&& spoken.includes(reason) && (hover ?? '').includes(reason), spoken || '没有说明')
|
||||
if (shown) {
|
||||
// 左边写着「已封存」,按钮却不能点:原因要写在屏幕上,触屏看不到悬停提示
|
||||
const visible = page.locator('[data-bundle-blocked="shown"]')
|
||||
const box = await visible.boundingBox()
|
||||
check(`${label}:左边写着「已封存」时,原因直接写在按钮旁`, await page.locator('[data-seal="sealed"]').isVisible()
|
||||
&& await visible.isVisible() && !!box && box.width > 20 && (await visible.innerText()).includes(reason))
|
||||
}
|
||||
await button.click({ force: true }).catch(() => {})
|
||||
check(`${syn.replace('bundle-provenance-', '')}:点了也不请求导出接口`, hits.length === 0)
|
||||
check(`${label}:点了也不请求导出接口`, hits.length === 0)
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
{
|
||||
const { ctx, page } = await preview('bundle-provenance-sealed', { width: 390 })
|
||||
for (const syn of ['bundle-provenance-sealed', 'bundle-provenance-again']) {
|
||||
const { ctx, page } = await preview(syn, { width: 390 })
|
||||
try {
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
const box = await page.locator('[data-bundle-export]').boundingBox()
|
||||
check('390 宽:凭证条不横向溢出,导出按钮在屏幕内', overflow <= 0 && !!box && box.x >= 0 && box.x + box.width <= 390,
|
||||
`溢出 ${overflow}px`)
|
||||
check(`390 宽(${syn.replace('bundle-provenance-', '')}):凭证条不横向溢出,导出按钮在屏幕内`,
|
||||
overflow <= 0 && !!box && box.x >= 0 && box.x + box.width <= 390, `溢出 ${overflow}px`)
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user