@tea-agent/loop-agent 0.23.1 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +1 -1
  3. package/bin/agent-worker.js +0 -0
  4. package/dist/commands/init.js +2 -2
  5. package/dist/executors/shell-executor.js +20 -7
  6. package/dist/shared/operator/capabilities.js +475 -2
  7. package/dist/task/dag-source-paths.js +50 -0
  8. package/dist/task/frontend-project-capability.js +316 -0
  9. package/dist/task/task-demand-routing.js +432 -0
  10. package/dist/worker/console/app-data.js +2 -0
  11. package/dist/worker/console/chat/artifact-card.js +23 -0
  12. package/dist/worker/console/chat/chat-event-store.js +495 -0
  13. package/dist/worker/console/chat/chat-ui-policy.js +25 -0
  14. package/dist/worker/console/chat/compaction-errors.js +64 -0
  15. package/dist/worker/console/chat/composer-draft-store.js +45 -0
  16. package/dist/worker/console/chat/context-panel.js +54 -0
  17. package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
  18. package/dist/worker/console/chat/explore-tools.js +299 -0
  19. package/dist/worker/console/chat/human-gate-card.js +37 -0
  20. package/dist/worker/console/chat/interview-adapter.js +149 -0
  21. package/dist/worker/console/chat/operation-card.js +23 -0
  22. package/dist/worker/console/chat/pi-console-config.js +158 -0
  23. package/dist/worker/console/chat/pi-runtime.js +581 -43
  24. package/dist/worker/console/chat/repo-browser.js +140 -0
  25. package/dist/worker/console/chat/repo-walk.js +116 -0
  26. package/dist/worker/console/chat/resource-loader.js +18 -17
  27. package/dist/worker/console/chat/routes.js +1359 -65
  28. package/dist/worker/console/chat/runtime-context.js +24 -0
  29. package/dist/worker/console/chat/runtime-selection.js +37 -0
  30. package/dist/worker/console/chat/session-store.js +210 -11
  31. package/dist/worker/console/chat/shortcuts.js +15 -0
  32. package/dist/worker/console/chat/tool-adapter.js +81 -194
  33. package/dist/worker/console/chat/tools.js +72 -48
  34. package/dist/worker/console/chat/usage.js +37 -0
  35. package/dist/worker/console/chat/workspace-landing.js +56 -0
  36. package/dist/worker/console/dag-confirmation.js +42 -8
  37. package/dist/worker/console/human-gate-token.js +130 -0
  38. package/dist/worker/console/interview/grill-me.js +28 -5
  39. package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
  40. package/dist/worker/console/operation-runner.js +6 -2
  41. package/dist/worker/console/operation-sse.js +26 -0
  42. package/dist/worker/console/operator-actions.js +420 -7
  43. package/dist/worker/console/server.js +14 -2
  44. package/dist/worker/console/static/assets/index-Cv68Ge5n.js +27 -0
  45. package/dist/worker/console/static/assets/index-Dp_2sKGk.css +1 -0
  46. package/dist/worker/console/static/index.html +2 -2
  47. package/dist/worker/observe/routes.js +8 -4
  48. package/dist/workflows/dag/backend-test-intake-context.js +10 -12
  49. package/dist/workflows/dag/backend-test-markdown-workflow.js +232 -47
  50. package/dist/workflows/dag/backend-test-result-contract.js +233 -0
  51. package/dist/workflows/dag/frontend-implementation-contract.js +23 -17
  52. package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
  53. package/dist/workflows/dag/frontend-project-capability.js +1 -316
  54. package/dist/workflows/dag/frontend-worktree-diff.js +4 -1
  55. package/dist/workflows/dag/init-hybrid.js +46 -43
  56. package/dist/workflows/dag/rerun-plan.js +6 -10
  57. package/dist/workflows/dag/task-demand-routing.js +1 -383
  58. package/docs/README.md +1 -1
  59. package/docs/architecture/README.md +5 -5
  60. package/docs/architecture/evolution.md +4 -4
  61. package/docs/architecture/worker-and-feature.md +1 -1
  62. package/docs/templates/backend-test-dag.json +2 -2
  63. package/docs/templates/frontend-implementation-contract.schema.json +1 -1
  64. package/harness.json +1 -1
  65. package/package.json +1 -1
  66. package/skills/frontend-implementation/SKILL.md +7 -0
  67. package/skills/frontend-implementation/references/node-contracts.md +1 -1
  68. package/skills/frontend-verification/SKILL.md +4 -3
  69. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  70. package/dist/worker/console/static/assets/index-DVl7Jxt5.js +0 -25
  71. package/dist/worker/console/static/assets/index-lVcIr9Ju.css +0 -1
@@ -97,13 +97,19 @@ export async function inspectBackendTestEnvironment(input) {
97
97
  }
98
98
  fixtures.push(...extractFixtures(content));
99
99
  }
100
- const htmlRenderer = /--html\b/.test(input.pytestHelp)
100
+ const hasPytestHtml = /--html\b/.test(input.pytestHelp);
101
+ const htmlRenderer = hasPytestHtml
101
102
  ? "pytest-html --self-contained-html"
102
- : "loop-agent built-in JUnit-to-HTML";
103
+ : "missing pytest-html (required: pip install pytest-html)";
103
104
  const warnings = [
104
105
  ...(pytestConfigs.length === 0
105
106
  ? ["No explicit pytest configuration file was found."]
106
107
  : []),
108
+ ...(!hasPytestHtml
109
+ ? [
110
+ "pytest-html is not installed; the backend-test pipeline requires it to render the self-contained HTML report. Install with: pip install pytest-html",
111
+ ]
112
+ : []),
107
113
  ...(conftestFiles.length === 0
108
114
  ? ["No bounded conftest.py candidate was found."]
109
115
  : []),
@@ -721,11 +727,21 @@ function escapeHtml(value) {
721
727
  function reportOutputLines(value, prefix) {
722
728
  if (!value?.trim())
723
729
  return [];
730
+ // Accept both the underscore form ("HTTP_REQUEST") and the space-separated
731
+ // form ("HTTP REQUEST") that some generated pytest helpers emit. Keep the
732
+ // original line text so downstream parsers can read the kv payload.
733
+ const underscorePrefix = prefix;
734
+ const spacePrefix = prefix.replaceAll("_", " ");
724
735
  return value
725
736
  .split(/\r?\n/)
726
737
  .map((line) => line.trim())
727
- .filter((line) => line.startsWith(prefix))
728
- .map((line) => redactBackendTestOutput(line.slice(prefix.length).trim()).slice(0, 4000));
738
+ .filter((line) => line.startsWith(underscorePrefix) || line.startsWith(spacePrefix))
739
+ .map((line) => {
740
+ const trimmed = line.startsWith(underscorePrefix)
741
+ ? line.slice(underscorePrefix.length)
742
+ : line.slice(spacePrefix.length);
743
+ return redactBackendTestOutput(trimmed.trim()).slice(0, 4000);
744
+ });
729
745
  }
730
746
  function requestResponseSummary(result) {
731
747
  const combined = [result.stdout, result.stderr].filter(Boolean).join("\n");
@@ -734,6 +750,101 @@ function requestResponseSummary(result) {
734
750
  responses: reportOutputLines(combined, "HTTP_RESPONSE"),
735
751
  };
736
752
  }
753
+ const HTTP_STATUS_TEXT = {
754
+ 200: "OK",
755
+ 201: "Created",
756
+ 204: "No Content",
757
+ 400: "Bad Request",
758
+ 401: "Unauthorized",
759
+ 403: "Forbidden",
760
+ 404: "Not Found",
761
+ 409: "Conflict",
762
+ 500: "Internal Server Error",
763
+ };
764
+ /**
765
+ * Parse the kv payload of an HTTP log line into a structured entry.
766
+ *
767
+ * Accepts two shapes emitted by generated pytest helpers:
768
+ * - legacy: a bare JSON object ({"method":"POST",...})
769
+ * - current: kv pairs (method=POST url=... parameters={...} / status_code=201 result={...})
770
+ */
771
+ function parseHttpLogPayload(line, kind) {
772
+ const trimmed = line.trim();
773
+ if (!trimmed)
774
+ return {};
775
+ let kv = {};
776
+ // Try JSON first (legacy HTTP_REQUEST {"method":...} shape).
777
+ if (trimmed.startsWith("{")) {
778
+ try {
779
+ const parsed = JSON.parse(trimmed);
780
+ for (const [key, value] of Object.entries(parsed)) {
781
+ kv[key.toLowerCase()] = typeof value === "string" ? value : safeStringify(value);
782
+ }
783
+ }
784
+ catch {
785
+ // Fall through to kv extraction below.
786
+ }
787
+ }
788
+ if (Object.keys(kv).length === 0) {
789
+ kv = extractKvPairs(trimmed);
790
+ }
791
+ if (kind === "request") {
792
+ return {
793
+ method: kv.method,
794
+ url: kv.url,
795
+ body: kv.parameters ?? kv.body ?? kv.json ?? kv.data ?? kv.query ?? kv.payload,
796
+ };
797
+ }
798
+ const status = kv.status_code ?? kv.status;
799
+ return {
800
+ method: kv.method,
801
+ url: kv.url,
802
+ status,
803
+ statusText: kv.status_text ?? (status ? HTTP_STATUS_TEXT[status] : undefined),
804
+ body: kv.result ?? kv.body ?? kv.text,
805
+ };
806
+ }
807
+ function extractKvPairs(value) {
808
+ const result = {};
809
+ // Split on top-level spaces but keep {...}/[...] payloads intact.
810
+ let depth = 0;
811
+ let buffer = "";
812
+ const flush = () => {
813
+ const segment = buffer.trim();
814
+ buffer = "";
815
+ if (!segment)
816
+ return;
817
+ const eq = segment.indexOf("=");
818
+ if (eq <= 0)
819
+ return;
820
+ const key = segment.slice(0, eq).trim();
821
+ const val = segment.slice(eq + 1).trim();
822
+ if (key)
823
+ result[key] = val;
824
+ };
825
+ for (const ch of value) {
826
+ if (ch === "{" || ch === "[")
827
+ depth += 1;
828
+ else if (ch === "}" || ch === "]")
829
+ depth = Math.max(0, depth - 1);
830
+ if (ch === " " && depth === 0) {
831
+ flush();
832
+ }
833
+ else {
834
+ buffer += ch;
835
+ }
836
+ }
837
+ flush();
838
+ return result;
839
+ }
840
+ function safeStringify(value) {
841
+ try {
842
+ return JSON.stringify(value, null, 2);
843
+ }
844
+ catch {
845
+ return String(value);
846
+ }
847
+ }
737
848
  function reportDetails(title, values, emptyText) {
738
849
  const body = values.length > 0
739
850
  ? values.map((value) => `<pre>${escapeHtml(value)}</pre>`).join("")
@@ -771,6 +882,96 @@ function conclusionIcon(failed) {
771
882
  function reportDetailSection(title, content, open = false) {
772
883
  return `<details class="report-detail"${open ? " open" : ""}><summary>${escapeHtml(title)}</summary><div class="report-detail-body">${content}</div></details>`;
773
884
  }
885
+ /** Render a pretty "prettified" body block: if it looks like JSON/kv, reformat; otherwise show raw. */
886
+ function renderLogBody(raw, emptyText) {
887
+ const text = (raw ?? "").trim();
888
+ if (!text || text === "{}" || text === "null") {
889
+ return `<pre style="white-space:pre-wrap;background:#f6f8fa;color:#1f2937;padding:10px 12px;border-radius:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.78rem;margin-top:6px;border:1px solid #eceef2;line-height:1.6">${escapeHtml(emptyText)}</pre>`;
890
+ }
891
+ let pretty = text;
892
+ // Best-effort: pretty-print JSON payloads.
893
+ if (text.startsWith("{") || text.startsWith("[")) {
894
+ try {
895
+ pretty = JSON.stringify(JSON.parse(text), null, 2);
896
+ }
897
+ catch {
898
+ // Keep original kv text.
899
+ }
900
+ }
901
+ else if (text.includes(":") || text.includes("=")) {
902
+ // kv-style "key: value" or "key=value" → one per line.
903
+ pretty = text
904
+ .replace(/\s+(?=[A-Za-z_]+[:=])/g, "\n")
905
+ .trim();
906
+ }
907
+ return `<pre style="white-space:pre-wrap;background:#f6f8fa;color:#1f2937;padding:10px 12px;border-radius:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.78rem;margin-top:6px;border:1px solid #eceef2;line-height:1.6">${escapeHtml(pretty)}</pre>`;
908
+ }
909
+ function statusColor(status) {
910
+ if (status === "passed")
911
+ return { fg: "#067647", bg: "#ecfdf3" };
912
+ if (status === "skipped")
913
+ return { fg: "#9a6700", bg: "#fffaeb" };
914
+ return { fg: "#b42318", bg: "#fef3f2" };
915
+ }
916
+ function methodBadge(method) {
917
+ const m = (method ?? "?").toUpperCase();
918
+ const bg = m === "GET" ? "#2563eb" : m === "POST" ? "#16a34a" : m === "DELETE" ? "#b42318" : m === "PUT" || m === "PATCH" ? "#d97706" : "#475467";
919
+ return `<span style="display:inline-block;padding:2px 8px;border-radius:5px;background:${bg};color:#fff;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.72rem;font-weight:700">${escapeHtml(m)}</span>`;
920
+ }
921
+ function statusPill(status, fallback) {
922
+ const code = Number(status);
923
+ if (!Number.isFinite(code) || code === 0) {
924
+ return `<span style="color:#98a2b3;font-size:0.74rem">${escapeHtml(fallback)}</span>`;
925
+ }
926
+ const color = code >= 200 && code < 300 ? { fg: "#067647", bg: "#ecfdf3" } : code >= 400 ? { fg: "#b42318", bg: "#fef3f2" } : { fg: "#9a6700", bg: "#fffaeb" };
927
+ return `<span style="display:inline-block;padding:2px 9px;border-radius:999px;font-weight:700;font-size:0.72rem;color:${color.fg};background:${color.bg}">${code}</span>`;
928
+ }
929
+ /** Pair request lines with their matching response lines (by method+url or order). */
930
+ function pairHttpCalls(requests, responses) {
931
+ const parsedRequests = requests.map((line) => parseHttpLogPayload(line, "request"));
932
+ const parsedResponses = responses.map((line) => parseHttpLogPayload(line, "response"));
933
+ const used = new Set();
934
+ return parsedRequests.map((request) => {
935
+ let matchIndex = -1;
936
+ for (let i = 0; i < parsedResponses.length; i += 1) {
937
+ if (used.has(i))
938
+ continue;
939
+ const candidate = parsedResponses[i];
940
+ if (candidate.method && request.method && candidate.method.toUpperCase() === request.method.toUpperCase()
941
+ && candidate.url && request.url && candidate.url === request.url) {
942
+ matchIndex = i;
943
+ break;
944
+ }
945
+ }
946
+ // Fallback: first unused response in order.
947
+ if (matchIndex < 0) {
948
+ for (let i = 0; i < parsedResponses.length; i += 1) {
949
+ if (!used.has(i)) {
950
+ matchIndex = i;
951
+ break;
952
+ }
953
+ }
954
+ }
955
+ if (matchIndex >= 0) {
956
+ used.add(matchIndex);
957
+ return { request, response: parsedResponses[matchIndex] };
958
+ }
959
+ return { request };
960
+ });
961
+ }
962
+ function renderHttpCallCard(call) {
963
+ // method/url come from the request line; status/result come from the response.
964
+ // Some log formats (HTTP RESPONSE status=... result=...) omit method/url on
965
+ // the response line, so prefer the request for those fields.
966
+ const method = call.request.method ?? call.response?.method;
967
+ const url = call.request.url ?? call.response?.url ?? "未知 URL";
968
+ const header = `<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">${methodBadge(method)}<code style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.78rem;color:#344054">${escapeHtml(url)}</code><span style="flex:1"></span>${call.response ? statusPill(call.response.status, call.response.statusText ?? "") : "<span style=\"color:#98a2b3;font-size:0.74rem\">无响应</span>"}${call.response?.statusText ? `<span style="color:#98a2b3;font-size:0.74rem">${escapeHtml(call.response.statusText)}</span>` : ""}</div>`;
969
+ const requestBlock = `<div style="color:#667085;font-size:0.74rem;font-weight:600;margin-bottom:3px;margin-top:10px">请求参数 / 请求体</div>${renderLogBody(call.request.body, "无")}`;
970
+ const responseBlock = call.response
971
+ ? `<div style="color:#667085;font-size:0.74rem;font-weight:600;margin-bottom:3px;margin-top:10px">响应结果</div>${renderLogBody(call.response.body, "无")}`
972
+ : "";
973
+ return `<div style="border:1px solid #eef0f3;border-radius:10px;padding:12px 14px;margin-top:10px;background:#fcfdff">${header}${requestBlock}${responseBlock}</div>`;
974
+ }
774
975
  export function renderBackendTestHtml(input) {
775
976
  const failed = input.parsed.failed + input.parsed.errors > 0;
776
977
  const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
@@ -778,53 +979,39 @@ export function renderBackendTestHtml(input) {
778
979
  const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
779
980
  const traceability = parseAdvisorySummary(input.traceabilitySummary);
780
981
  const failures = input.parsed.cases.filter((result) => result.status !== "passed");
781
- const rows = input.parsed.cases.map((result) => {
782
- const caseId = junitCaseId(result.name) ?? "未关联";
783
- const item = catalog.get(caseId);
784
- const io = requestResponseSummary(result);
785
- const failureDetails = result.status === "passed"
786
- ? ""
787
- : `<div class="failure-detail"><strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${result.details ? reportDetailSection("失败详情", `<pre>${escapeHtml(result.details)}</pre>`) : ""}</div>`;
788
- return `<article class="case-card result-${result.status}"><div class="case-head"><div><span class="case-id">${escapeHtml(caseId)}</span><h3>${escapeHtml(item?.title ?? result.name)}</h3><p>${escapeHtml(item?.scenario ?? "未从 Markdown 用例提取场景说明。")}</p></div><div class="case-outcome"><span class="status ${result.status}">${humanStatus(result.status)}</span><span class="duration">${formatDuration(result.durationMs)}</span></div></div><div class="case-meta"><span>自动化用例</span><code>${escapeHtml(result.name)}</code></div><div class="io-grid">${reportDetails("接口请求参数", io.requests, "本次 JUnit 未捕获请求日志。")}${reportDetails("接口响应结果", io.responses, "本次 JUnit 未捕获响应日志。")}</div>${failureDetails}</article>`;
789
- }).join("");
790
- const failureOverview = failures.length > 0
791
- ? `<div class="failure-list">${failures.map((result) => {
792
- const caseId = junitCaseId(result.name) ?? "未关联";
793
- return `<article><div>${advisoryBadge(result.status === "skipped" ? "Unavailable" : "FAIL")}<strong>${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</strong></div><p>${escapeHtml(result.message || humanStatus(result.status))}</p></article>`;
794
- }).join("")}</div>`
795
- : '<div class="empty-state">本轮没有失败、错误或跳过用例。</div>';
796
- const conclusion = failed
797
- ? `本轮测试未通过:${input.parsed.failed + input.parsed.errors} 条用例失败或错误。`
798
- : `本轮测试通过:${input.parsed.passed} 条用例执行成功。`;
799
- const executionOverview = `<div class="metric-grid"><div><span>用例总数</span><strong>${input.parsed.tests}</strong></div><div><span>通过</span><strong>${input.parsed.passed}</strong></div><div><span>失败 / 错误</span><strong>${input.parsed.failed} / ${input.parsed.errors}</strong></div><div><span>跳过</span><strong>${input.parsed.skipped}</strong></div><div><span>通过率</span><strong>${(passRate * 100).toFixed(2)}%</strong></div><div><span>总耗时</span><strong>${formatDuration(input.parsed.durationMs)}</strong></div></div>`;
800
- const qualityDetails = `<div class="quality-grid"><div class="quality-card"><header><strong>Markdown 用例校验</strong>${advisoryBadge(caseValidation.status)}</header><p>Findings:${caseValidation.findings ?? "不可用"}</p><p>${escapeHtml(caseValidation.firstFinding)}</p></div><div class="quality-card"><header><strong>Markdown → pytest 追溯</strong>${advisoryBadge(traceability.status)}</header><p>Findings:${traceability.findings ?? "不可用"}</p><p>${escapeHtml(traceability.firstFinding)}</p></div></div><div class="evidence-line"><span>执行环境</span>${escapeHtml(input.environmentSummary || "不可用")}</div>`;
801
- return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(input.title)}</title><style>:root{color-scheme:light;--ink:#182230;--muted:#667085;--line:#dce3ec;--panel:#f6f8fb;--brand:#163a63;--accent:#2e6da4;--pass:#067647;--pass-bg:#ecfdf3;--fail:#b42318;--fail-bg:#fef3f2;--skip:#9a6700;--skip-bg:#fffaeb;--shadow:0 18px 45px rgba(28,55,90,.09)}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top left,#dcecff 0,transparent 32rem),linear-gradient(180deg,#edf3fa 0,#f8fafc 34rem);color:var(--ink);font-family:Inter,system-ui,"Microsoft YaHei","PingFang SC",sans-serif;line-height:1.65}header,main{max-width:1280px;margin:auto}header{padding:3.5rem 2rem 1.75rem}main{padding:0 2rem 4rem}h1{margin:.3rem 0;color:var(--brand);font-size:clamp(2rem,4vw,3rem);letter-spacing:-.03em}h2{margin:0 0 1rem;color:var(--brand)}h3{margin:.35rem 0 .15rem;font-size:1.12rem}.eyebrow{color:var(--accent);font-weight:850;letter-spacing:.12em}.subtitle,.muted,.case-head p{color:var(--muted)}section,.report-detail{background:rgba(255,255,255,.94);border:1px solid rgba(220,227,236,.95);border-radius:1.15rem;padding:1.5rem;margin-top:1rem;box-shadow:var(--shadow)}.conclusion{display:flex;align-items:center;gap:1rem;padding:1rem 1.25rem;border-left:5px solid ${failed ? "var(--fail)" : "var(--pass)"};border-radius:.8rem;background:${failed ? "var(--fail-bg)" : "var(--pass-bg)"};color:${failed ? "var(--fail)" : "var(--pass)"}}.conclusion-icon{width:4rem;height:4rem;flex:0 0 4rem;display:block;overflow:visible}.conclusion-icon circle:first-child{fill:currentColor;fill-opacity:.12;stroke:currentColor;stroke-width:2}.conclusion-icon path{fill:none;stroke:currentColor;stroke-width:3.2;stroke-linecap:round;stroke-linejoin:round}.conclusion-icon .icon-dot{fill:currentColor;stroke:none}.conclusion-copy{min-width:0}.conclusion-copy strong{display:block;font-size:1.05rem}.conclusion-copy span{display:block;font-size:.9rem;font-weight:400}.metric-grid{display:grid;grid-template-columns:repeat(6,minmax(7rem,1fr));gap:.75rem;margin-top:1rem}.metric-grid>div{padding:1rem;border:1px solid var(--line);border-radius:.85rem;background:white}.metric-grid span,.metric-grid small{display:block;color:var(--muted);font-size:.88rem}.metric-grid strong{display:block;font-size:1.45rem;font-weight:700}.quality-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.quality-card{padding:1rem;border:1px solid var(--line);border-radius:.85rem;background:white}.quality-card header{display:flex;align-items:center;justify-content:space-between;padding:0;margin:0 0 .5rem}.quality-card p{margin:.25rem 0;color:var(--muted)}.status{display:inline-flex;align-items:center;padding:.18rem .62rem;border-radius:999px;font-weight:800;font-size:.86rem}.status.passed{color:var(--pass);background:var(--pass-bg)}.status.failure,.status.error{color:var(--fail);background:var(--fail-bg)}.status.skipped{color:var(--skip);background:var(--skip-bg)}.failure-list{display:grid;gap:.75rem}.failure-list article{border-left:4px solid var(--fail);background:var(--fail-bg);padding:.85rem 1rem;border-radius:.65rem}.empty-state{padding:1rem;border:1px dashed var(--line);border-radius:.75rem;color:var(--muted);background:var(--panel)}.case-list{display:grid;gap:1rem}.case-card{border:1px solid var(--line);border-radius:1rem;padding:1.15rem;background:linear-gradient(145deg,#fff,#fbfcfe);box-shadow:0 8px 24px rgba(28,55,90,.06)}.case-card.result-failure,.case-card.result-error{border-left:5px solid var(--fail)}.case-card.result-passed{border-left:5px solid var(--pass)}.case-head{display:flex;justify-content:space-between;gap:1rem;align-items:flex-start}.case-head p{margin:.2rem 0}.case-id{display:inline-block;color:var(--accent);font-weight:850;letter-spacing:.04em}.case-outcome{display:flex;align-items:center;gap:.75rem;white-space:nowrap}.duration{color:var(--muted);font-variant-numeric:tabular-nums}.case-meta{display:flex;gap:.75rem;align-items:center;margin:.85rem 0;padding:.65rem .8rem;border-radius:.7rem;background:var(--panel);color:var(--muted)}code{color:var(--brand);font-weight:700}.io-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem}.io-details{border:1px solid var(--line);border-radius:.75rem;padding:.7rem;background:white}.io-details summary,.report-detail summary{cursor:pointer;color:var(--brand);font-weight:800}.io-details summary span{float:right;color:var(--muted)}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:18rem;overflow:auto;background:#101828;color:#eef4ff;padding:.85rem;border-radius:.6rem;font-size:.82rem}.failure-detail{margin-top:.8rem;padding:.8rem;border-radius:.7rem;background:var(--fail-bg);color:var(--fail)}.report-detail{padding:0;overflow:hidden}.report-detail summary{list-style:none;padding:1rem 1.25rem}.report-detail summary::-webkit-details-marker{display:none}.report-detail summary::after{content:"+";float:right;font-size:1.2rem;font-weight:400;color:var(--muted)}.report-detail[open] summary::after{content:"−"}.report-detail-body{border-top:1px solid var(--line);padding:1.25rem}.evidence-line{display:flex;gap:.75rem;align-items:baseline;padding:.7rem .8rem;background:var(--panel);border-radius:.65rem;font-size:.9rem}.evidence-line span{color:var(--muted);min-width:8rem}.subtitle{margin:.2rem 0 0}@media(max-width:900px){.metric-grid{grid-template-columns:repeat(2,1fr)}.quality-grid,.io-grid{grid-template-columns:1fr}.case-head{display:block}.case-outcome{margin-top:.65rem}header,main{padding-left:1rem;padding-right:1rem}}@media(max-width:420px){.conclusion{align-items:flex-start}.conclusion-icon{width:3rem;height:3rem;flex-basis:3rem}.metric-grid{grid-template-columns:1fr 1fr}}</style></head><body><header><div class="eyebrow">后端自动化测试报告</div><h1>${escapeHtml(input.title)}</h1><p class="subtitle">测试结论、质量状态与每条接口用例的请求/响应事实均来自同一次 pytest 执行。</p></header><main><section><h2>当前结论</h2><div class="conclusion">${conclusionIcon(failed)}<div class="conclusion-copy"><strong>${escapeHtml(failed ? "测试未通过" : "测试通过")}</strong><span>${escapeHtml(conclusion)}</span></div></div>${executionOverview}</section>${reportDetailSection("质量校验", qualityDetails, true)}${reportDetailSection("失败概览", failureOverview, failures.length > 0)}${reportDetailSection("用例执行结果", `<div class="case-list">${rows || '<div class="empty-state">未发现可展示的 JUnit testcase。</div>'}</div>`, true)}</main></body></html>`;
802
- }
803
- function renderBackendTestHtmlLegacy(input) {
804
- const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
805
- const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
806
- const rows = input.parsed.cases.map((result) => {
982
+ const headColor = failed ? { fg: "#b42318", bg: "linear-gradient(135deg,#fef3f2 0,#fee4e2 100%)", border: "#fda29b", icon: "✗" } : { fg: "#067647", bg: "linear-gradient(135deg,#ecfdf3 0,#d1fadf 100%)", border: "#abefc6", icon: "✓" };
983
+ const caseCards = input.parsed.cases.map((result) => {
807
984
  const caseId = junitCaseId(result.name) ?? "未关联";
808
985
  const item = catalog.get(caseId);
809
986
  const io = requestResponseSummary(result);
810
- const failureDetail = result.status === "passed"
987
+ const calls = pairHttpCalls(io.requests, io.responses);
988
+ const statusColors = statusColor(result.status);
989
+ const logSection = calls.length > 0
990
+ ? `<div style="margin-top:12px;display:flex;align-items:baseline;gap:8px"><span style="color:#17365d;font-size:0.9rem;font-weight:700">执行日志</span><span style="color:#98a2b3;font-size:0.76rem">共 ${calls.length} 次接口调用</span></div>${calls.map(renderHttpCallCard).join("")}`
991
+ : "";
992
+ const failureBlock = result.status === "passed"
811
993
  ? ""
812
- : `<div class="failure-detail"><strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${result.details ? `<details><summary>失败详情</summary><pre>${escapeHtml(result.details)}</pre></details>` : ""}</div>`;
813
- return `<article class="case-card result-${result.status}"><div class="case-head"><div><span class="case-id">${escapeHtml(caseId)}</span><h3>${escapeHtml(item?.title ?? result.name)}</h3><p>${escapeHtml(item?.scenario ?? "未从 Markdown 用例提取场景说明。")}</p></div><div class="case-outcome"><span class="status ${result.status}">${humanStatus(result.status)}</span><span class="duration">${formatDuration(result.durationMs)}</span></div></div><div class="case-meta"><span>自动化用例</span><code>${escapeHtml(result.name)}</code></div><div class="io-grid">${reportDetails("接口请求参数", io.requests, "本次 JUnit 未捕获请求日志。")}${reportDetails("接口响应结果", io.responses, "本次 JUnit 未捕获响应日志。")}</div>${failureDetail}</article>`;
994
+ : `<div style="margin-top:12px;padding:10px 12px;border-radius:8px;background:#fef3f2;border:1px solid #fda29b;color:#b42318;font-size:0.84rem"><strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${result.details ? `<details style="margin-top:6px"><summary style="cursor:pointer;color:#b42318;font-size:0.78rem">失败详情</summary><pre style="white-space:pre-wrap;background:#f6f8fa;color:#1f2937;padding:10px 12px;border-radius:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.78rem;margin-top:6px;border:1px solid #eceef2;line-height:1.6">${escapeHtml(result.details)}</pre></details>` : ""}</div>`;
995
+ const scriptPath = item?.scriptPath ?? inferredScriptPath(result.classname);
996
+ return `<details style="background:#fff;border:1px solid #e6eaf0;border-radius:12px;margin-top:10px"><summary style="display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;list-style:none"><span style="background:#e8edf5;color:#2b3a55;padding:2px 9px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.76rem;font-weight:600;white-space:nowrap">${escapeHtml(caseId)}</span><div style="flex:1;min-width:0"><div style="font-weight:600;color:#172033;font-size:0.92rem">${escapeHtml(item?.title ?? result.name)}</div><code style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.74rem;color:#98a2b3">${escapeHtml(result.name)}</code></div><span style="color:#98a2b3;font-size:0.8rem;white-space:nowrap">${formatDuration(result.durationMs)}</span><span style="display:inline-block;padding:2px 10px;border-radius:999px;font-weight:700;font-size:0.72rem;color:${statusColors.fg};background:${statusColors.bg}">${humanStatus(result.status)}</span><span style="color:#aab2bf;font-size:0.8rem">▾</span></summary><div style="padding:4px 16px 14px 16px;border-top:1px solid #f0f3f7"><div style="margin-top:10px"><div style="color:#667085;font-size:0.74rem;font-weight:600;margin-bottom:3px">自动化脚本</div><code style="background:#eef2f7;color:#344054;padding:1px 6px;border-radius:5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.8rem">${escapeHtml(scriptPath)}</code></div>${item?.scenario ? `<div style="margin-top:12px"><div style="color:#667085;font-size:0.74rem;font-weight:600;margin-bottom:3px">验收场景</div><div style="color:#475467;font-size:0.86rem;line-height:1.75">${escapeHtml(item.scenario)}</div></div>` : ""}${logSection}${failureBlock}</div></details>`;
814
997
  }).join("");
815
- const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
816
- const traceability = parseAdvisorySummary(input.traceabilitySummary);
817
- const failures = input.parsed.cases.filter((result) => result.status !== "passed");
998
+ const metricCard = (label, value, valueColor = "#172033") => `<div style="padding:14px 16px;border:1px solid #e3e8ef;border-radius:12px;background:#fbfcfe"><span style="color:#667085;font-size:0.78rem">${escapeHtml(label)}</span><div style="font-size:22px;font-weight:700;color:${valueColor}">${value}</div></div>`;
999
+ const metrics = `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px;margin-top:14px">${metricCard("用例总数", input.parsed.tests)}${metricCard("通过", input.parsed.passed, "#067647")}${metricCard("失败", input.parsed.failed)}${metricCard("错误", input.parsed.errors)}${metricCard("跳过", input.parsed.skipped)}<div style="padding:14px 16px;border:1px solid #e3e8ef;border-radius:12px;background:#fbfcfe"><span style="color:#667085;font-size:0.78rem">通过率</span><div style="font-size:22px;font-weight:700;color:#067647">${(passRate * 100).toFixed(2)}%</div><span style="color:#667085;font-size:0.78rem">耗时 ${formatDuration(input.parsed.durationMs)}</span></div></div>`;
1000
+ const summaryBanner = `<div style="display:flex;align-items:center;gap:16px;padding:16px 18px;border-radius:14px;background:${headColor.bg};border:1px solid ${headColor.border}"><div style="flex:0 0 auto;width:44px;height:44px;border-radius:999px;background:${headColor.fg};display:flex;align-items:center;justify-content:center;color:#fff;font-size:24px;font-weight:900;box-shadow:0 4px 12px ${headColor.fg}4d">${headColor.icon}</div><div><div style="color:${headColor.fg};font-size:1.05rem;font-weight:800;line-height:1.3">${failed ? "本轮测试未通过" : "本轮测试通过"}</div><div style="color:${headColor.fg};font-size:0.88rem;margin-top:2px">${failed ? `${input.parsed.failed + input.parsed.errors} 条用例失败或错误` : `${input.parsed.passed} 条用例全部执行成功`}</div></div></div>`;
1001
+ const qualityBlock = `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">质量校验</div><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-top:8px">${renderQualityCard("Markdown 用例校验", caseValidation)}${renderQualityCard("Markdown → pytest 追溯", traceability)}</div></div>`;
818
1002
  const failureOverview = failures.length > 0
819
- ? `<div class="failure-list">${failures.map((result) => {
1003
+ ? `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">失败概览</div><div style="display:grid;gap:10px;margin-top:8px">${failures.map((result) => {
820
1004
  const caseId = junitCaseId(result.name) ?? "未关联";
821
- return `<article><div>${advisoryBadge(result.status === "skipped" ? "Unavailable" : "FAIL")}<strong>${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</strong></div><p>${escapeHtml(result.message || humanStatus(result.status))}</p></article>`;
822
- }).join("")}</div>`
823
- : '<div class="empty-state">本轮没有失败、错误或跳过用例。</div>';
824
- const conclusion = input.parsed.failed + input.parsed.errors === 0
825
- ? `本轮测试通过:${input.parsed.passed} 条用例执行成功。`
826
- : `本轮测试未通过:${input.parsed.failed + input.parsed.errors} 条用例失败或错误,请优先查看失败原因。`;
827
- return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(input.title)}</title><style>:root{color-scheme:light;--ink:#182230;--muted:#667085;--line:#dce3ec;--panel:#f6f8fb;--brand:#163a63;--accent:#2e6da4;--pass:#067647;--pass-bg:#ecfdf3;--fail:#b42318;--fail-bg:#fef3f2;--skip:#9a6700;--skip-bg:#fffaeb;--shadow:0 18px 45px rgba(28,55,90,.09)}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top left,#dcecff 0,transparent 32rem),linear-gradient(180deg,#edf3fa 0,#f8fafc 34rem);color:var(--ink);font-family:Inter,system-ui,"Microsoft YaHei","PingFang SC",sans-serif;line-height:1.65}header,main{max-width:1280px;margin:auto}header{padding:3.5rem 2rem 1.75rem}main{padding:0 2rem 4rem}h1{margin:.3rem 0;color:var(--brand);font-size:clamp(2rem,4vw,3rem);letter-spacing:-.03em}h2{margin:0 0 1rem;color:var(--brand)}h3{margin:.35rem 0 .15rem;font-size:1.12rem}.eyebrow{color:var(--accent);font-weight:850;letter-spacing:.12em}.subtitle,.muted,.case-head p{color:var(--muted)}section{background:rgba(255,255,255,.94);border:1px solid rgba(220,227,236,.95);border-radius:1.15rem;padding:1.5rem;margin-top:1rem;box-shadow:var(--shadow)}.banner{padding:1rem 1.25rem;border-left:5px solid currentColor;border-radius:.8rem;background:${input.parsed.failed + input.parsed.errors === 0 ? "var(--pass-bg)" : "var(--fail-bg)"};color:${input.parsed.failed + input.parsed.errors === 0 ? "var(--pass)" : "var(--fail)"};font-weight:800}.cards{display:grid;grid-template-columns:repeat(6,minmax(7rem,1fr));gap:.75rem;margin-top:1rem}.card,.quality-card{padding:1rem;border:1px solid var(--line);border-radius:.85rem;background:white}.card span,.card small{display:block;color:var(--muted)}.card strong{font-size:1.55rem}.quality-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.quality-card header{display:flex;align-items:center;justify-content:space-between;padding:0;margin:0 0 .5rem}.quality-card p{margin:.25rem 0;color:var(--muted)}.status{display:inline-flex;align-items:center;padding:.18rem .62rem;border-radius:999px;font-weight:800;font-size:.86rem}.status.passed{color:var(--pass);background:var(--pass-bg)}.status.failure,.status.error{color:var(--fail);background:var(--fail-bg)}.status.skipped{color:var(--skip);background:var(--skip-bg)}.failure-list{display:grid;gap:.75rem}.failure-list article{border-left:4px solid var(--fail);background:var(--fail-bg);padding:.85rem 1rem;border-radius:.65rem}.empty-state{padding:1rem;border:1px dashed var(--line);border-radius:.75rem;color:var(--muted);background:var(--panel)}.case-list{display:grid;gap:1rem}.case-card{border:1px solid var(--line);border-radius:1rem;padding:1.15rem;background:linear-gradient(145deg,#fff,#fbfcfe);box-shadow:0 8px 24px rgba(28,55,90,.06)}.case-card.result-failure,.case-card.result-error{border-left:5px solid var(--fail)}.case-card.result-passed{border-left:5px solid var(--pass)}.case-head{display:flex;justify-content:space-between;gap:1rem;align-items:flex-start}.case-head p{margin:.2rem 0}.case-id{display:inline-block;color:var(--accent);font-weight:850;letter-spacing:.04em}.case-outcome{display:flex;align-items:center;gap:.75rem;white-space:nowrap}.duration{color:var(--muted);font-variant-numeric:tabular-nums}.case-meta{display:flex;gap:.75rem;align-items:center;margin:.85rem 0;padding:.65rem .8rem;border-radius:.7rem;background:var(--panel);color:var(--muted)}code{color:var(--brand);font-weight:700}.io-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem}.io-details{border:1px solid var(--line);border-radius:.75rem;padding:.7rem;background:white}.io-details summary{cursor:pointer;color:var(--brand);font-weight:800}.io-details summary span{float:right;color:var(--muted)}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:18rem;overflow:auto;background:#101828;color:#eef4ff;padding:.85rem;border-radius:.6rem;font-size:.82rem}.failure-detail{margin-top:.8rem;padding:.8rem;border-radius:.7rem;background:var(--fail-bg);color:var(--fail)}details summary{cursor:pointer}.subtitle{margin:.2rem 0 0}@media(max-width:900px){.cards{grid-template-columns:repeat(2,1fr)}.quality-grid,.io-grid{grid-template-columns:1fr}.case-head{display:block}.case-outcome{margin-top:.65rem}header,main{padding-left:1rem;padding-right:1rem}}</style></head><body><header><div class="eyebrow">后端自动化测试报告</div><h1>${escapeHtml(input.title)}</h1><p class="subtitle">测试结论、质量状态与每条接口用例的请求/响应事实均来自同一次 pytest 执行。</p></header><main><section><h2>测试结论</h2><div class="banner">${escapeHtml(conclusion)}</div><div class="cards"><div class="card"><span>用例总数</span><strong>${input.parsed.tests}</strong></div><div class="card"><span>通过</span><strong>${input.parsed.passed}</strong></div><div class="card"><span>失败</span><strong>${input.parsed.failed}</strong></div><div class="card"><span>错误</span><strong>${input.parsed.errors}</strong></div><div class="card"><span>跳过</span><strong>${input.parsed.skipped}</strong></div><div class="card"><span>通过率 / 总耗时</span><strong>${(passRate * 100).toFixed(2)}%</strong><small>${formatDuration(input.parsed.durationMs)}</small></div></div></section><section><h2>质量校验</h2><div class="quality-grid"><div class="quality-card"><header><strong>Markdown 用例校验</strong>${advisoryBadge(caseValidation.status)}</header><p>Findings:${caseValidation.findings ?? "不可用"}</p><p>${escapeHtml(caseValidation.firstFinding)}</p></div><div class="quality-card"><header><strong>Markdown → pytest 追溯</strong>${advisoryBadge(traceability.status)}</header><p>Findings:${traceability.findings ?? "不可用"}</p><p>${escapeHtml(traceability.firstFinding)}</p></div></div></section><section><h2>失败概览</h2>${failureOverview}</section><section><h2>用例执行结果</h2><div class="case-list">${rows || '<div class="empty-state">未发现可展示的 JUnit testcase。</div>'}</div></section></main></body></html>`;
1005
+ const c = statusColor(result.status);
1006
+ return `<div style="border-left:4px solid ${c.fg};background:${c.bg};padding:10px 12px;border-radius:8px"><div style="font-weight:600;color:#172033;font-size:0.88rem">${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</div><div style="color:${c.fg};font-size:0.82rem;margin-top:2px">${escapeHtml(result.message || humanStatus(result.status))}</div></div>`;
1007
+ }).join("")}</div></div>`
1008
+ : "";
1009
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(input.title)}</title></head><body style="margin:0;background:linear-gradient(180deg,#eef2f7 0,#f7f9fc 40rem);color:#172033;font-family:system-ui,'Microsoft YaHei','PingFang SC',sans-serif;line-height:1.6"><div style="max-width:1000px;margin:0 auto;padding:32px 22px 56px"><div style="padding:8px 0 4px"><div style="color:#315f93;font-weight:700;letter-spacing:0.06em;font-size:0.76rem">后端自动化测试报告</div><h1 style="margin:6px 0 0;color:#17365d;font-size:24px;line-height:1.35">${escapeHtml(input.title)}</h1><p style="color:#667085;margin:8px 0 0;font-size:0.9rem">执行事实来自同一次 pytest 执行;用例默认折叠,点击任意用例可展开执行日志(接口、请求参数、响应结果)与检查点。</p></div><div style="background:#fff;border:1px solid #e3e8ef;border-radius:16px;padding:18px 20px;margin-top:16px;box-shadow:0 1px 2px rgba(16,24,40,0.04)">${summaryBanner}${metrics}</div><div style="background:#fff;border:1px solid #e3e8ef;border-radius:16px;padding:18px 20px;margin-top:16px;box-shadow:0 1px 2px rgba(16,24,40,0.04)">${qualityBlock}<div style="margin-top:12px;padding:10px 12px;background:#f6f8fa;border-radius:8px;font-size:0.82rem;color:#475467"><strong style="color:#667085">执行环境</strong>:${escapeHtml((input.environmentSummary || "不可用").split("\n").slice(0, 3).join(" ").slice(0, 200))}</div></div>${failureOverview}<div style="margin-top:18px"><div style="display:flex;align-items:baseline;justify-content:space-between;margin:0 4px 6px"><span style="color:#17365d;font-size:16px;font-weight:700">用例执行明细</span><span style="color:#98a2b3;font-size:0.8rem">共 ${input.parsed.cases.length} 条 · 点击展开</span></div>${caseCards || '<div style="padding:16px;border:1px dashed #e3e8ef;border-radius:12px;color:#667085;background:#fbfcfe;text-align:center">未发现可展示的用例。</div>'}</div></div></body></html>`;
1010
+ }
1011
+ function renderQualityCard(title, summary) {
1012
+ const colors = summary.status === "PASS" ? { fg: "#067647", bg: "#ecfdf3" } : summary.status === "FAIL" ? { fg: "#b42318", bg: "#fef3f2" } : { fg: "#9a6700", bg: "#fffaeb" };
1013
+ const label = summary.status === "Unavailable" ? "不可用" : summary.status;
1014
+ return `<div style="padding:12px 14px;border:1px solid #e3e8ef;border-radius:10px;background:#fbfcfe"><div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px"><strong style="color:#17365d;font-size:0.88rem">${escapeHtml(title)}</strong><span style="display:inline-block;padding:2px 9px;border-radius:999px;font-weight:700;font-size:0.72rem;color:${colors.fg};background:${colors.bg}">${label}</span></div><div style="color:#667085;font-size:0.78rem">Findings:${summary.findings ?? "不可用"}</div><div style="color:#475467;font-size:0.8rem;margin-top:2px">${escapeHtml(summary.firstFinding)}</div></div>`;
828
1015
  }
829
1016
  export function renderBackendTestFacts(input) {
830
1017
  const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
@@ -843,7 +1030,7 @@ export function renderBackendTestFacts(input) {
843
1030
  `| ${input.pytestExitCode === 0 ? "通过" : "未通过"} | ${input.parsed.tests} | ${input.parsed.passed} | ${input.parsed.failed} | ${input.parsed.errors} | ${input.parsed.skipped} | ${(passRate * 100).toFixed(2)}% | ${formatDuration(input.parsed.durationMs)} |`,
844
1031
  "",
845
1032
  `- Pytest exit code: ${input.pytestExitCode}`,
846
- "- JUnit valid: yes",
1033
+ "- Pytest-html report: valid",
847
1034
  "- HTML valid: yes",
848
1035
  "",
849
1036
  "## 质量校验",
@@ -872,8 +1059,6 @@ export function renderBackendTestFacts(input) {
872
1059
  "",
873
1060
  "## 证据与完整校验原文",
874
1061
  "",
875
- `- JUnit: ${input.junitRelativePath}`,
876
- `- JUnit SHA-256: ${createHash("sha256").update(input.junitContent).digest("hex")}`,
877
1062
  `- HTML: ${input.htmlRelativePath}`,
878
1063
  `- HTML SHA-256: ${createHash("sha256").update(input.htmlContent).digest("hex")}`,
879
1064
  "",
@@ -354,6 +354,239 @@ export function parseJunitXml(xml) {
354
354
  failures: failures.slice(0, MAX_FAILURES),
355
355
  };
356
356
  }
357
+ const HTML_ENTITY_MAP = {
358
+ "&amp;": "&",
359
+ "&lt;": "<",
360
+ "&gt;": ">",
361
+ "&quot;": '"',
362
+ "&#34;": '"',
363
+ "&#39;": "'",
364
+ "&apos;": "'",
365
+ };
366
+ function decodeHtmlEntities(value) {
367
+ return value.replace(/&(?:amp|lt|gt|quot|#34|#39|apos);/g, (entity) => HTML_ENTITY_MAP[entity] ?? entity);
368
+ }
369
+ function parseDurationLabelMs(raw) {
370
+ if (!raw)
371
+ return undefined;
372
+ const value = Number.parseFloat(raw);
373
+ if (!Number.isFinite(value))
374
+ return undefined;
375
+ if (/\bms\b/i.test(raw))
376
+ return Math.round(value);
377
+ if (/\bsec|s\b/i.test(raw))
378
+ return Math.round(value * 1000);
379
+ // pytest-html commonly emits bare milliseconds without a unit suffix.
380
+ return Math.round(value);
381
+ }
382
+ /**
383
+ * Extract the pytest-html 4.x JSON island from a self-contained HTML report.
384
+ *
385
+ * pytest-html 4.x embeds the full report payload as an HTML-entity-escaped
386
+ * JSON string in `<div id="data-container" data-jsonblob="...">`. This helper
387
+ * locates that attribute, decodes entities and parses the JSON. Fail-closed on
388
+ * a non-pytest-html document so the pipeline never silently drops test facts.
389
+ */
390
+ export function extractPytestHtmlReportData(html) {
391
+ const match = html.match(/id=["']data-container["'][^>]*data-jsonblob=["']([^"']*)["']/i);
392
+ if (!match || !match[1]) {
393
+ throw new Error("invalid pytest-html report: missing data-container data-jsonblob island");
394
+ }
395
+ const decoded = decodeHtmlEntities(match[1]);
396
+ try {
397
+ return JSON.parse(decoded);
398
+ }
399
+ catch (error) {
400
+ throw new Error(`invalid pytest-html report: data-jsonblob is not valid JSON (${error instanceof Error ? error.message : String(error)})`);
401
+ }
402
+ }
403
+ /**
404
+ * Parse a self-contained pytest-html 4.x report into the same ParsedJunit
405
+ * shape used by the HTML/facts renderers, so the downstream rendering path
406
+ * stays uniform regardless of the evidence source.
407
+ *
408
+ * Unlike JUnit, pytest-html stores per-test captured stdout in a `log` field
409
+ * (with section markers like "Captured stdout call"). We surface that as the
410
+ * case `stdout` so HTTP_REQUEST/HTTP_RESPONSE lines remain visible in the
411
+ * per-case cards.
412
+ */
413
+ export function parsePytestHtmlReport(html) {
414
+ const data = extractPytestHtmlReportData(html);
415
+ const entries = Object.entries(data.tests ?? {});
416
+ if (entries.length === 0) {
417
+ throw new Error("invalid pytest-html report: no test entries in data-jsonblob");
418
+ }
419
+ const cases = [];
420
+ const failures = [];
421
+ let passed = 0;
422
+ let failed = 0;
423
+ let errors = 0;
424
+ let skipped = 0;
425
+ for (const [nodeId, records] of entries) {
426
+ const record = records[0];
427
+ if (!record)
428
+ continue;
429
+ const rawResult = (record.result ?? "").toLowerCase();
430
+ const testId = record.testId ?? nodeId;
431
+ const { classname, name } = splitPytestNodeId(testId);
432
+ const durationMs = parseDurationLabelMs(record.duration);
433
+ const capturedLog = record.log ?? "";
434
+ // pytest-html collapses captured stdout/stderr into a single `log` field
435
+ // annotated with section markers. Preserve the whole log as stdout so the
436
+ // HTTP_REQUEST/HTTP_RESPONSE lines stay reachable for the per-case card.
437
+ const stdout = splitPytestHtmlLogSections(capturedLog).stdout || undefined;
438
+ const stderr = splitPytestHtmlLogSections(capturedLog).stderr || undefined;
439
+ if (rawResult === "passed") {
440
+ passed += 1;
441
+ cases.push({
442
+ classname,
443
+ name,
444
+ durationMs,
445
+ status: "passed",
446
+ ...(stdout ? { stdout } : {}),
447
+ ...(stderr ? { stderr } : {}),
448
+ });
449
+ }
450
+ else if (rawResult === "failed") {
451
+ failed += 1;
452
+ const message = extractPytestHtmlFailureMessage(capturedLog) || "failure";
453
+ const summary = truncate(message);
454
+ failures.push({ classname, name, message: summary, kind: "failure" });
455
+ cases.push({
456
+ classname,
457
+ name,
458
+ durationMs,
459
+ status: "failure",
460
+ message: summary,
461
+ details: capturedLog || summary,
462
+ ...(stdout ? { stdout } : {}),
463
+ ...(stderr ? { stderr } : {}),
464
+ });
465
+ }
466
+ else if (rawResult === "error") {
467
+ errors += 1;
468
+ const message = extractPytestHtmlFailureMessage(capturedLog) || "error";
469
+ const summary = truncate(message);
470
+ failures.push({ classname, name, message: summary, kind: "error" });
471
+ cases.push({
472
+ classname,
473
+ name,
474
+ durationMs,
475
+ status: "error",
476
+ message: summary,
477
+ details: capturedLog || summary,
478
+ ...(stdout ? { stdout } : {}),
479
+ ...(stderr ? { stderr } : {}),
480
+ });
481
+ }
482
+ else if (rawResult === "skipped" || rawResult === "xfailed") {
483
+ skipped += 1;
484
+ cases.push({
485
+ classname,
486
+ name,
487
+ durationMs,
488
+ status: "skipped",
489
+ ...(stdout ? { stdout } : {}),
490
+ ...(stderr ? { stderr } : {}),
491
+ });
492
+ }
493
+ else {
494
+ // Unknown outcome label: treat as error to stay fail-safe.
495
+ errors += 1;
496
+ const message = `unexpected pytest-html result label: ${record.result ?? "(empty)"}`;
497
+ const summary = truncate(message);
498
+ failures.push({ classname, name, message: summary, kind: "error" });
499
+ cases.push({
500
+ classname,
501
+ name,
502
+ durationMs,
503
+ status: "error",
504
+ message: summary,
505
+ details: capturedLog || summary,
506
+ ...(stdout ? { stdout } : {}),
507
+ ...(stderr ? { stderr } : {}),
508
+ });
509
+ }
510
+ }
511
+ const tests = cases.length;
512
+ return {
513
+ tests,
514
+ passed,
515
+ failed,
516
+ errors,
517
+ skipped,
518
+ durationMs: undefined,
519
+ cases,
520
+ failures: failures.slice(0, MAX_FAILURES),
521
+ };
522
+ }
523
+ function splitPytestNodeId(testId) {
524
+ // pytest-html uses node ids like "path/to/test_x.py::TestClass::test_name".
525
+ const sepIndex = testId.lastIndexOf("::");
526
+ if (sepIndex < 0) {
527
+ return { classname: "unknown", name: testId };
528
+ }
529
+ const prefix = testId.slice(0, sepIndex);
530
+ const name = testId.slice(sepIndex + 2);
531
+ const lastParen = name.indexOf("(");
532
+ const cleanName = lastParen >= 0 ? name.slice(0, lastParen) : name;
533
+ const moduleSep = prefix.lastIndexOf("::");
534
+ const filePart = moduleSep >= 0 ? prefix.slice(moduleSep + 2) : prefix;
535
+ const moduleWithoutExt = filePart.replace(/\.py$/i, "");
536
+ return { classname: moduleWithoutExt.replace(/\//g, ".") || "unknown", name: cleanName };
537
+ }
538
+ function splitPytestHtmlLogSections(log) {
539
+ if (!log)
540
+ return { stdout: "", stderr: "" };
541
+ // pytest-html HTML-entity-escapes the captured log content (e.g. `&quot;`
542
+ // for `"`). Decode entities first so downstream HTTP log parsers see the
543
+ // real JSON/kv payload rather than escaped markup.
544
+ const decoded = decodeHtmlEntities(log);
545
+ // pytest-html interleaves captured stdout/stderr with section markers like
546
+ // "----------------------------- Captured stdout call -----------------------------".
547
+ // String.split includes capture-group matches in the result array, so the
548
+ // layout is [beforeText, label, body, label, body, ...]. Iterate label/body
549
+ // pairs starting at index 1.
550
+ const sections = decoded.split(/^-+ Captured (stdout|stderr|log|call|setup|teardown)(?: call| setup| teardown)? -+$/m);
551
+ let stdout = "";
552
+ let stderr = "";
553
+ for (let index = 1; index < sections.length - 1; index += 2) {
554
+ const label = sections[index]?.toLowerCase() ?? "";
555
+ const body = sections[index + 1] ?? "";
556
+ if (label.includes("stderr")) {
557
+ stderr = `${stderr}${body}`.trim();
558
+ }
559
+ else {
560
+ stdout = `${stdout}${body}`.trim();
561
+ }
562
+ }
563
+ if (!stdout && !stderr) {
564
+ // No recognizable section markers: treat the whole log as stdout so HTTP
565
+ // request/response lines remain visible.
566
+ stdout = decoded.trim();
567
+ }
568
+ return { stdout, stderr };
569
+ }
570
+ function extractPytestHtmlFailureMessage(log) {
571
+ if (!log)
572
+ return "";
573
+ // pytest-html failure logs contain assertion lines prefixed with "E " and a
574
+ // trailing location line like "test_x.py:N: AssertionError". Prefer the
575
+ // explicit AssertionError/Error line; fall back to the last "E " line.
576
+ const assertionLine = log
577
+ .split(/\r?\n/)
578
+ .map((line) => line.trim())
579
+ .find((line) => /:\s*AssertionError/.test(line));
580
+ if (assertionLine)
581
+ return assertionLine.replace(/^.*?:\s*/, "");
582
+ const eLines = log
583
+ .split(/\r?\n/)
584
+ .filter((line) => /^E\s+/.test(line))
585
+ .map((line) => line.replace(/^E\s+/, "").trim());
586
+ if (eLines.length > 0)
587
+ return eLines[eLines.length - 1];
588
+ return "";
589
+ }
357
590
  export function deriveBackendTestResult(input) {
358
591
  assertNoSecrets("commandSummary", input.commandSummary);
359
592
  const emptyJunitMeta = {