@tea-agent/loop-agent 0.25.1 → 0.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.25.2] - 2026-07-30
6
+
7
+ ### 重点更新
8
+
9
+ - 加固后端测试中文 HTML 报告的用例关联与解析逻辑,提升报告的准确性与可读性
10
+
11
+ ### 改进
12
+
13
+ - 改进基于 class 的 pytest node id 解析,准确提取真实脚本路径,避免显示为 unknown.py
14
+ - 当用例名称缺失后端 ID 时,支持按函数名与 Markdown catalog 进行回退关联
15
+ - 规范化 [REQ]/[RESP] HTTP 日志块,增强日志兼容性
16
+
17
+ ### 修复
18
+
19
+ - 修复成功执行路径下原生 pytest-html 报告未正确保留的问题,现会将其另存为 backend-test-pytest-native.html
20
+
5
21
  ## [0.25.1] - 2026-07-30
6
22
 
7
23
  ### 重点更新
@@ -587,11 +587,13 @@ async function executeBackendTestPipeline(input, meta) {
587
587
  throw new Error(`pytest did not complete with a reportable exit code: ${pytestExitCode}`);
588
588
  }
589
589
  const parsed = parsePytestHtmlReport(pytestHtmlContent);
590
+ // Keep native pytest-html (data-jsonblob) for audit before styled overwrite.
591
+ const nativeHtmlPath = await writeRunReport(meta.runDir, "backend-test-pytest-native.html", pytestHtmlContent);
590
592
  // Bind Result v1 from the native pytest-html report BEFORE overwriting with the
591
593
  // styled renderer (which drops the data-jsonblob island).
592
594
  const resultArtifact = await materializeBackendTestResultFromPytestHtml({
593
595
  runDir: meta.runDir,
594
- htmlRelativePath: "reports/backend-test.html",
596
+ htmlRelativePath: "reports/backend-test-pytest-native.html",
595
597
  htmlContent: pytestHtmlContent,
596
598
  pytestExitCode,
597
599
  });
@@ -682,7 +684,7 @@ async function executeBackendTestPipeline(input, meta) {
682
684
  });
683
685
  const l5Path = await writeRunReport(meta.runDir, "backend-test-l5-dashboard.html", l5Html);
684
686
  const sanitizedOutputs = results.map((result) => redactBackendTestOutput(result.stdout));
685
- outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, `l5-dashboard=${l5Path}`, `result=${resultArtifact.path}`, facts);
687
+ outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `nativeHtml=${nativeHtmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, `l5-dashboard=${l5Path}`, `result=${resultArtifact.path}`, facts);
686
688
  }
687
689
  else if (pipeline === "contracts") {
688
690
  const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
@@ -774,15 +774,57 @@ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
774
774
  function junitCaseId(name) {
775
775
  return symbolCaseId(name) ?? name.match(CASE_ID)?.[0];
776
776
  }
777
+ /** Prefer function-name Case ID; fall back to first BE-* in free text (docstring/log). */
778
+ function extractCaseIdFromText(value) {
779
+ if (!value?.trim())
780
+ return undefined;
781
+ return junitCaseId(value) ?? value.match(CASE_ID)?.[0];
782
+ }
783
+ function resolveReportCaseId(result, catalog) {
784
+ const fromName = extractCaseIdFromText(result.name);
785
+ if (fromName && catalog.has(fromName))
786
+ return fromName;
787
+ if (fromName)
788
+ return fromName;
789
+ // Match catalog by pytest function/method name when Case ID is only in docstring/md.
790
+ const byFunction = [...catalog.values()].find((item) => item.testFunctions.includes(result.name));
791
+ if (byFunction)
792
+ return byFunction.id;
793
+ const fromStdout = extractCaseIdFromText(result.stdout);
794
+ if (fromStdout && catalog.has(fromStdout))
795
+ return fromStdout;
796
+ if (fromStdout)
797
+ return fromStdout;
798
+ const fromDetails = extractCaseIdFromText(result.details);
799
+ if (fromDetails && catalog.has(fromDetails))
800
+ return fromDetails;
801
+ return "未关联";
802
+ }
777
803
  function humanStatus(status) {
778
804
  return status === "passed" ? "通过" : status === "failure" ? "失败" : status === "error" ? "错误" : "跳过";
779
805
  }
780
806
  function formatDuration(durationMs) {
781
807
  return durationMs === undefined ? "未记录" : `${(durationMs / 1000).toFixed(3)} 秒`;
782
808
  }
783
- function inferredScriptPath(classname) {
784
- const moduleName = classname.split(".").filter((part) => part && !/^Test/.test(part)).join("/");
785
- return `${moduleName || "unknown"}.py`;
809
+ function inferredScriptPath(classname, filePath) {
810
+ if (filePath?.trim()) {
811
+ return filePath.replaceAll("\\", "/").replace(/^\.\//, "");
812
+ }
813
+ const moduleName = classname
814
+ .split(".")
815
+ .filter((part) => part && !/^Test[A-Z_]/.test(part) && part !== "Test")
816
+ .join("/");
817
+ if (!moduleName || moduleName === "unknown")
818
+ return "unknown.py";
819
+ return moduleName.endsWith(".py") ? moduleName : `${moduleName}.py`;
820
+ }
821
+ function resolveReportScriptPath(result, item) {
822
+ if (item?.scriptPath)
823
+ return item.scriptPath;
824
+ if (result.filePath?.trim()) {
825
+ return result.filePath.replaceAll("\\", "/").replace(/^\.\//, "");
826
+ }
827
+ return inferredScriptPath(result.classname, result.filePath);
786
828
  }
787
829
  export function redactBackendTestOutput(value) {
788
830
  return value
@@ -815,8 +857,97 @@ function reportOutputLines(value, prefix) {
815
857
  return redactBackendTestOutput(trimmed.trim()).slice(0, 4000);
816
858
  });
817
859
  }
860
+ /**
861
+ * Normalize alternate HTTP log dialects into HTTP_REQUEST / HTTP_RESPONSE lines.
862
+ * Supports common project helpers that emit [REQ]/[RESP] block style instead of
863
+ * the contract prefixes (keeps original HTTP_* lines unchanged).
864
+ */
865
+ export function normalizeBackendTestHttpLogText(value) {
866
+ if (!value.trim())
867
+ return value;
868
+ const lines = value.split(/\r?\n/);
869
+ const out = [];
870
+ let i = 0;
871
+ while (i < lines.length) {
872
+ const raw = lines[i];
873
+ const line = raw.trim();
874
+ // Already-contract lines: pass through.
875
+ if (/^(HTTP_REQUEST|HTTP REQUEST|HTTP_RESPONSE|HTTP RESPONSE)\b/i.test(line)) {
876
+ out.push(raw);
877
+ i += 1;
878
+ continue;
879
+ }
880
+ // [REQ] METHOD path OR [REQ] METHOD url
881
+ const reqHead = line.match(/^\[REQ\]\s+([A-Z]+)\s+(\S+)\s*$/i);
882
+ if (reqHead) {
883
+ const method = reqHead[1].toUpperCase();
884
+ const url = reqHead[2];
885
+ let body = "";
886
+ let j = i + 1;
887
+ // Optional [REQ body] then JSON/text until separator or next tag.
888
+ if (j < lines.length && /^\[REQ\s*body\]/i.test(lines[j].trim())) {
889
+ j += 1;
890
+ const bodyLines = [];
891
+ while (j < lines.length &&
892
+ !/^[=-]{3,}\s*$/.test(lines[j].trim()) &&
893
+ !/^\[(?:REQ|RESP)/i.test(lines[j].trim()) &&
894
+ !/^(HTTP_REQUEST|HTTP_RESPONSE|HTTP REQUEST|HTTP RESPONSE)\b/i.test(lines[j].trim())) {
895
+ bodyLines.push(lines[j]);
896
+ j += 1;
897
+ }
898
+ body = bodyLines.join("\n").trim();
899
+ }
900
+ const payload = { method, url };
901
+ if (body) {
902
+ try {
903
+ payload.json = JSON.parse(body);
904
+ }
905
+ catch {
906
+ payload.body = body.slice(0, 4000);
907
+ }
908
+ }
909
+ out.push(`HTTP_REQUEST ${JSON.stringify(payload)}`);
910
+ i = j;
911
+ continue;
912
+ }
913
+ // [RESP status] 200 OR [RESP] 200
914
+ const respHead = line.match(/^\[RESP(?:\s+status)?\]\s+(\d{3})\b/i);
915
+ if (respHead) {
916
+ const status_code = Number(respHead[1]);
917
+ let body = "";
918
+ let j = i + 1;
919
+ if (j < lines.length && /^\[RESP\s*body\]/i.test(lines[j].trim())) {
920
+ j += 1;
921
+ const bodyLines = [];
922
+ while (j < lines.length &&
923
+ !/^[=-]{3,}\s*$/.test(lines[j].trim()) &&
924
+ !/^\[(?:REQ|RESP)/i.test(lines[j].trim()) &&
925
+ !/^(HTTP_REQUEST|HTTP_RESPONSE|HTTP REQUEST|HTTP RESPONSE)\b/i.test(lines[j].trim())) {
926
+ bodyLines.push(lines[j]);
927
+ j += 1;
928
+ }
929
+ body = bodyLines.join("\n").trim();
930
+ }
931
+ const payload = { status_code };
932
+ if (body) {
933
+ try {
934
+ payload.result = JSON.parse(body);
935
+ }
936
+ catch {
937
+ payload.result = body.slice(0, 4000);
938
+ }
939
+ }
940
+ out.push(`HTTP_RESPONSE ${JSON.stringify(payload)}`);
941
+ i = j;
942
+ continue;
943
+ }
944
+ out.push(raw);
945
+ i += 1;
946
+ }
947
+ return out.join("\n");
948
+ }
818
949
  function requestResponseSummary(result) {
819
- const combined = [result.stdout, result.stderr].filter(Boolean).join("\n");
950
+ const combined = normalizeBackendTestHttpLogText([result.stdout, result.stderr].filter(Boolean).join("\n"));
820
951
  return {
821
952
  requests: reportOutputLines(combined, "HTTP_REQUEST"),
822
953
  responses: reportOutputLines(combined, "HTTP_RESPONSE"),
@@ -1053,8 +1184,8 @@ export function renderBackendTestHtml(input) {
1053
1184
  const failures = input.parsed.cases.filter((result) => result.status !== "passed");
1054
1185
  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: "✓" };
1055
1186
  const caseCards = input.parsed.cases.map((result) => {
1056
- const caseId = junitCaseId(result.name) ?? "未关联";
1057
- const item = catalog.get(caseId);
1187
+ const caseId = resolveReportCaseId(result, catalog);
1188
+ const item = caseId === "未关联" ? undefined : catalog.get(caseId);
1058
1189
  const io = requestResponseSummary(result);
1059
1190
  const calls = pairHttpCalls(io.requests, io.responses);
1060
1191
  const statusColors = statusColor(result.status);
@@ -1064,7 +1195,7 @@ export function renderBackendTestHtml(input) {
1064
1195
  const failureBlock = result.status === "passed"
1065
1196
  ? ""
1066
1197
  : `<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>`;
1067
- const scriptPath = item?.scriptPath ?? inferredScriptPath(result.classname);
1198
+ const scriptPath = resolveReportScriptPath(result, item);
1068
1199
  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>`;
1069
1200
  }).join("");
1070
1201
  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>`;
@@ -1073,7 +1204,7 @@ export function renderBackendTestHtml(input) {
1073
1204
  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>`;
1074
1205
  const failureOverview = failures.length > 0
1075
1206
  ? `<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) => {
1076
- const caseId = junitCaseId(result.name) ?? "未关联";
1207
+ const caseId = resolveReportCaseId(result, catalog);
1077
1208
  const c = statusColor(result.status);
1078
1209
  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>`;
1079
1210
  }).join("")}</div></div>`
@@ -1342,9 +1473,9 @@ export function renderBackendTestFacts(input) {
1342
1473
  "| 用例编号 | 用例名称 | 自动化脚本 | 测试函数 | 结果 | 耗时 | 失败原因 |",
1343
1474
  "|---|---|---|---|---|---:|---|",
1344
1475
  ...input.parsed.cases.map((result) => {
1345
- const caseId = junitCaseId(result.name) ?? "未关联";
1346
- const item = catalog.get(caseId);
1347
- const script = item?.scriptPath ?? inferredScriptPath(result.classname);
1476
+ const caseId = resolveReportCaseId(result, catalog);
1477
+ const item = caseId === "未关联" ? undefined : catalog.get(caseId);
1478
+ const script = resolveReportScriptPath(result, item);
1348
1479
  return `| ${caseId} | ${item?.title ?? result.name} | \`${script}\` | \`${result.name}\` | ${humanStatus(result.status)} | ${formatDuration(result.durationMs)} | ${(result.message ?? "—").replaceAll("|", "\\|")} |`;
1349
1480
  }),
1350
1481
  "",
@@ -428,7 +428,8 @@ export function parsePytestHtmlReport(html) {
428
428
  continue;
429
429
  const rawResult = (record.result ?? "").toLowerCase();
430
430
  const testId = record.testId ?? nodeId;
431
- const { classname, name } = splitPytestNodeId(testId);
431
+ const { classname, name, filePath } = splitPytestNodeId(testId);
432
+ const filePathFields = filePath ? { filePath } : {};
432
433
  const durationMs = parseDurationLabelMs(record.duration);
433
434
  const capturedLog = record.log ?? "";
434
435
  // pytest-html collapses captured stdout/stderr into a single `log` field
@@ -441,6 +442,8 @@ export function parsePytestHtmlReport(html) {
441
442
  cases.push({
442
443
  classname,
443
444
  name,
445
+ ...filePathFields,
446
+ ...filePathFields,
444
447
  durationMs,
445
448
  status: "passed",
446
449
  ...(stdout ? { stdout } : {}),
@@ -455,6 +458,7 @@ export function parsePytestHtmlReport(html) {
455
458
  cases.push({
456
459
  classname,
457
460
  name,
461
+ ...filePathFields,
458
462
  durationMs,
459
463
  status: "failure",
460
464
  message: summary,
@@ -471,6 +475,7 @@ export function parsePytestHtmlReport(html) {
471
475
  cases.push({
472
476
  classname,
473
477
  name,
478
+ ...filePathFields,
474
479
  durationMs,
475
480
  status: "error",
476
481
  message: summary,
@@ -484,6 +489,7 @@ export function parsePytestHtmlReport(html) {
484
489
  cases.push({
485
490
  classname,
486
491
  name,
492
+ ...filePathFields,
487
493
  durationMs,
488
494
  status: "skipped",
489
495
  ...(stdout ? { stdout } : {}),
@@ -499,6 +505,7 @@ export function parsePytestHtmlReport(html) {
499
505
  cases.push({
500
506
  classname,
501
507
  name,
508
+ ...filePathFields,
502
509
  durationMs,
503
510
  status: "error",
504
511
  message: summary,
@@ -520,20 +527,44 @@ export function parsePytestHtmlReport(html) {
520
527
  failures: failures.slice(0, MAX_FAILURES),
521
528
  };
522
529
  }
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 };
530
+ /**
531
+ * Split a pytest node id into module classname, test name, and optional file path.
532
+ *
533
+ * Handles:
534
+ * - `testcase/test_x.py::test_name`
535
+ * - `testcase/test_x.py::TestClass::test_name` (class-based; previous code
536
+ * treated `TestClass` as the file segment and later filtered it to unknown.py)
537
+ * - Windows separators and parametrized names `test_name[param]`
538
+ */
539
+ export function splitPytestNodeId(testId) {
540
+ const normalized = testId.replaceAll("\\", "/").trim();
541
+ if (!normalized) {
542
+ return { classname: "unknown", name: "unknown" };
543
+ }
544
+ const parts = normalized.split("::").filter(Boolean);
545
+ if (parts.length < 2) {
546
+ // Bare id without "::" — keep as name; path unknown.
547
+ return { classname: "unknown", name: normalized };
548
+ }
549
+ let rawName = parts[parts.length - 1];
550
+ // Drop call args if present; keep parametrize brackets in display name strip for id match.
551
+ const paren = rawName.indexOf("(");
552
+ if (paren >= 0)
553
+ rawName = rawName.slice(0, paren);
554
+ const cleanName = rawName.trim() || "unknown";
555
+ // Prefer the first segment that looks like a .py file path.
556
+ const filePart = parts.find((part) => /\.py$/i.test(part)) ??
557
+ (parts[0].includes("/") || parts[0].includes(".") ? parts[0] : undefined);
558
+ const filePath = filePart
559
+ ? filePart.replace(/\\/g, "/")
560
+ : undefined;
561
+ const moduleWithoutExt = (filePath ?? "unknown").replace(/\.py$/i, "");
562
+ const classname = moduleWithoutExt.replaceAll("/", ".") || "unknown";
563
+ return {
564
+ classname,
565
+ name: cleanName,
566
+ ...(filePath ? { filePath } : {}),
567
+ };
537
568
  }
538
569
  function splitPytestHtmlLogSections(log) {
539
570
  if (!log)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.25.1",
3
+ "version": "0.25.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",