@tea-agent/loop-agent 0.25.2 → 0.25.4

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.
@@ -0,0 +1,109 @@
1
+ export const DEFAULT_RUN_DAG_PROGRESS_INTERVAL_MS = 30_000;
2
+ function formatDuration(durationMs) {
3
+ const totalSeconds = Math.max(0, Math.floor(durationMs / 1_000));
4
+ const hours = Math.floor(totalSeconds / 3_600);
5
+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
6
+ const seconds = totalSeconds % 60;
7
+ if (hours > 0)
8
+ return `${hours}h${String(minutes).padStart(2, "0")}m`;
9
+ if (minutes > 0)
10
+ return `${minutes}m${String(seconds).padStart(2, "0")}s`;
11
+ return `${seconds}s`;
12
+ }
13
+ function countStatuses(state) {
14
+ const counts = { finished: 0, running: 0, pending: 0, error: 0, skipped: 0 };
15
+ for (const node of Object.values(state.nodes)) {
16
+ if (node.status === "FINISHED")
17
+ counts.finished += 1;
18
+ else if (node.status === "RUNNING")
19
+ counts.running += 1;
20
+ else if (node.status === "ERROR")
21
+ counts.error += 1;
22
+ else if (node.status === "SKIPPED")
23
+ counts.skipped += 1;
24
+ else
25
+ counts.pending += 1;
26
+ }
27
+ return `finished=${counts.finished} running=${counts.running} pending=${counts.pending} error=${counts.error} skipped=${counts.skipped}`;
28
+ }
29
+ function nodeElapsedMs(node, now) {
30
+ const startedAt = node.startedAt ? Date.parse(node.startedAt) : Number.NaN;
31
+ return Number.isFinite(startedAt) ? Math.max(0, now - startedAt) : 0;
32
+ }
33
+ function activeNodeSummary(state, now) {
34
+ const running = Object.values(state.nodes).filter((node) => node.status === "RUNNING");
35
+ if (running.length === 0)
36
+ return "nodes=none";
37
+ return running
38
+ .map((node) => {
39
+ const details = [
40
+ `${node.id}(${formatDuration(nodeElapsedMs(node, now))}`,
41
+ node.livenessStatus ? `liveness=${node.livenessStatus}` : undefined,
42
+ node.currentAttempt ? `attempt=${node.currentAttempt}` : undefined,
43
+ ]
44
+ .filter(Boolean)
45
+ .join(" ");
46
+ return `${details})`;
47
+ })
48
+ .join(",");
49
+ }
50
+ /**
51
+ * Human-readable CLI progress written to stderr. The final run-dag JSON remains
52
+ * the only stdout payload, so machine callers can continue parsing stdout.
53
+ */
54
+ export function createRunDagProgressObserver(options = {}) {
55
+ const intervalMs = options.intervalMs ?? DEFAULT_RUN_DAG_PROGRESS_INTERVAL_MS;
56
+ const write = options.write ?? ((text) => process.stderr.write(text));
57
+ const now = options.now ?? Date.now;
58
+ let latestState;
59
+ let timer;
60
+ const emit = (message) => {
61
+ try {
62
+ write(`[run-dag] ${message}\n`);
63
+ }
64
+ catch {
65
+ // Progress is a derived CLI view and must never decide DAG execution.
66
+ }
67
+ };
68
+ const stop = () => {
69
+ if (timer)
70
+ clearInterval(timer);
71
+ timer = undefined;
72
+ };
73
+ const start = () => {
74
+ if (timer || intervalMs <= 0)
75
+ return;
76
+ timer = setInterval(() => {
77
+ if (!latestState)
78
+ return;
79
+ emit(`heartbeat run=${latestState.runId} ${countStatuses(latestState)} ${activeNodeSummary(latestState, now())}`);
80
+ }, intervalMs);
81
+ timer.unref?.();
82
+ };
83
+ const observer = {
84
+ onRunStart: (state) => {
85
+ latestState = state;
86
+ emit(`started run=${state.runId} nodes=${Object.keys(state.nodes).length} title=${JSON.stringify(state.title)}`);
87
+ start();
88
+ },
89
+ onNodeStart: (nodeId, state) => {
90
+ latestState = state;
91
+ const node = state.nodes[nodeId];
92
+ emit(`node-started run=${state.runId} node=${nodeId} executor=${node?.executor ?? "unknown"} ${countStatuses(state)}`);
93
+ },
94
+ onNodeOutput: (_nodeId, _chunk, state) => {
95
+ latestState = state;
96
+ },
97
+ onNodeFinish: (nodeId, state) => {
98
+ latestState = state;
99
+ const node = state.nodes[nodeId];
100
+ emit(`node-finished run=${state.runId} node=${nodeId} status=${node?.status ?? "unknown"} duration=${formatDuration(node?.durationMs ?? 0)} ${countStatuses(state)}`);
101
+ },
102
+ onRunFinish: (state) => {
103
+ latestState = state;
104
+ stop();
105
+ emit(`finished run=${state.runId} status=${state.status} ${countStatuses(state)}`);
106
+ },
107
+ };
108
+ return { observer, dispose: stop };
109
+ }
@@ -1,11 +1,22 @@
1
1
  import { parseRunDagArgs } from "../application/dag/args.js";
2
2
  import { runDagUseCase } from "../application/dag/run-dag.js";
3
+ import { createRunDagProgressObserver } from "./run-dag-progress.js";
3
4
  export { parseRunDagArgs };
4
5
  export async function runRunDag(repoRoot, rawArgs) {
5
6
  const parsed = parseRunDagArgs(rawArgs, repoRoot);
6
- const result = await runDagUseCase({
7
- repoRoot,
8
- ...parsed,
9
- });
10
- console.log(JSON.stringify(result, null, 2));
7
+ const { quiet, progressIntervalMs, ...runInput } = parsed;
8
+ const progress = quiet || parsed.dryRun || parsed.initOnly
9
+ ? undefined
10
+ : createRunDagProgressObserver({ intervalMs: progressIntervalMs });
11
+ try {
12
+ const result = await runDagUseCase({
13
+ repoRoot,
14
+ ...runInput,
15
+ observer: progress?.observer,
16
+ });
17
+ console.log(JSON.stringify(result, null, 2));
18
+ }
19
+ finally {
20
+ progress?.dispose();
21
+ }
11
22
  }
@@ -10,6 +10,8 @@ import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend
10
10
  import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
11
11
  import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
12
12
  import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
13
+ import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
14
+ import { renderFrontendTestHtmlReport } from "../workflows/dag/frontend-test-html-report.js";
13
15
  import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
14
16
  import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
15
17
  import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout, runFrontendFailureAssessGate, runFrontendRepairContractGate, } from "../workflows/dag/frontend-repair.js";
@@ -1143,6 +1145,30 @@ async function executeFrontendVerificationBundle(input, meta) {
1143
1145
  };
1144
1146
  }
1145
1147
  }
1148
+ async function executeFrontendTestCaseChecklist(input, meta) {
1149
+ const started = Date.now();
1150
+ try {
1151
+ const declaredAcIds = (meta.spec.sourceBinding?.requirementIds ?? []).filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
1152
+ const result = await validateFrontendCaseChecklist({ workspaceRoot: input.cwd, declaredAcIds });
1153
+ if (result.issues.length) {
1154
+ return { ok: false, stdout: "", stderr: `frontend-test checklist blocked: ${JSON.stringify(result.issues)}`, failureCategory: "invalid-output", durationMs: Date.now() - started };
1155
+ }
1156
+ return { ok: true, stdout: `frontend-test checklist ok cases=${result.caseCount} source=${path.basename(result.manifestPath)}`, stderr: "", failureCategory: "success", durationMs: Date.now() - started };
1157
+ }
1158
+ catch (error) {
1159
+ return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1160
+ }
1161
+ }
1162
+ async function executeFrontendTestHtmlReport(input, meta) {
1163
+ const started = Date.now();
1164
+ try {
1165
+ const output = await renderFrontendTestHtmlReport({ workspaceRoot: input.cwd, runDir: meta.runDir });
1166
+ return { ok: true, stdout: `Frontend test report: ${output.htmlPath}\nMarkdown: ${output.markdownPath}\nOutcome: ${output.outcome}\nCases: ${output.caseCount}`, stderr: "", failureCategory: "success", durationMs: Date.now() - started };
1167
+ }
1168
+ catch (error) {
1169
+ return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1170
+ }
1171
+ }
1146
1172
  async function executeFrontendTestEvidenceValidation(input) {
1147
1173
  const started = Date.now();
1148
1174
  try {
@@ -1275,9 +1301,15 @@ export async function executeDagShellNode(input, meta) {
1275
1301
  return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1276
1302
  }
1277
1303
  }
1304
+ if (shell?.frontendTestCaseChecklist) {
1305
+ return executeFrontendTestCaseChecklist(input, meta);
1306
+ }
1278
1307
  if (shell?.frontendTestEvidenceValidation) {
1279
1308
  return executeFrontendTestEvidenceValidation(input);
1280
1309
  }
1310
+ if (shell?.frontendTestHtmlReport) {
1311
+ return executeFrontendTestHtmlReport(input, meta);
1312
+ }
1281
1313
  if (shell?.backendTestPipeline) {
1282
1314
  return executeBackendTestPipelineWithWriteGuard(input, meta);
1283
1315
  }
@@ -7,7 +7,11 @@ import path from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import { parseJacocoXml, } from "./backend-test-coverage-contract.js";
9
9
  const execFileAsync = promisify(execFile);
10
- const CASE_ID = /\bBE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\b/g;
10
+ // Canonical contract is three digits (BE-MODULE-001). Readers accept legacy/
11
+ // model-drift two-digit suffixes and canonicalize them in memory so one bad
12
+ // heading width cannot collapse the whole manifest/report to zero cases.
13
+ const CASE_ID = /\bBE-[A-Z0-9_-]+-\d{2,3}\b/g;
14
+ const CANONICAL_CASE_ID = /\bBE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\b/;
11
15
  const AC_ID = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
12
16
  const SECRET = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization)\s*[:=]\s*\S+/i;
13
17
  const SECRET_ASSIGNMENT = /\b(password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization|cookie|set-cookie|credential)\b\s*[:=]\s*([^\r\n,;]+)/gi;
@@ -178,12 +182,27 @@ async function markdownFiles(root) {
178
182
  .map((entry) => path.join(directory, entry.name))
179
183
  .sort();
180
184
  }
185
+ function canonicalBackendCaseId(value) {
186
+ const upper = value.toUpperCase();
187
+ const match = upper.match(/^(BE-[A-Z0-9_-]+)-(\d{2,3})$/);
188
+ if (!match)
189
+ return upper;
190
+ const modulePart = match[1]
191
+ .replaceAll("_", "-")
192
+ .replace(/-+/g, "-")
193
+ .replace(/-$/, "");
194
+ return `${modulePart}-${match[2].padStart(3, "0")}`;
195
+ }
196
+ function caseIdsInText(value) {
197
+ return unique((value.match(CASE_ID) ?? []).map(canonicalBackendCaseId));
198
+ }
181
199
  function splitCases(markdown) {
182
200
  const headings = [
183
- ...markdown.matchAll(/^##\s+(BE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3})\b.*$/gm),
201
+ ...markdown.matchAll(/^##\s+(BE-[A-Z0-9_-]+-\d{2,3})\b.*$/gm),
184
202
  ];
185
203
  return headings.map((match, index) => ({
186
- id: match[1],
204
+ id: canonicalBackendCaseId(match[1]),
205
+ rawId: match[1],
187
206
  body: markdown.slice(match.index, headings[index + 1]?.index ?? markdown.length),
188
207
  }));
189
208
  }
@@ -396,6 +415,9 @@ export async function validateBackendMarkdownCases(input) {
396
415
  }
397
416
  for (const testCase of cases) {
398
417
  caseCount += 1;
418
+ if (!CANONICAL_CASE_ID.test(testCase.rawId)) {
419
+ findings.push(`${testCase.rawId} uses a non-canonical Case ID; use ${testCase.id} (hyphen-separated module, three-digit sequence)`);
420
+ }
399
421
  const previous = seen.get(testCase.id);
400
422
  if (previous) {
401
423
  findings.push(`duplicate case id ${testCase.id}: ${previous} and ${relativeFile}`);
@@ -545,8 +567,10 @@ function testFunctionRegion(input) {
545
567
  .join("\n");
546
568
  }
547
569
  function symbolCaseId(symbol) {
548
- const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{3})(?:_|$)/i);
549
- return match ? match[1].replaceAll("_", "-").toUpperCase() : undefined;
570
+ const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{2,3})(?:_|$)/i);
571
+ return match
572
+ ? canonicalBackendCaseId(match[1].replaceAll("_", "-"))
573
+ : undefined;
550
574
  }
551
575
  export async function validateBackendMarkdownTraceability(workspaceRoot) {
552
576
  const files = await markdownFiles(workspaceRoot);
@@ -606,7 +630,7 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
606
630
  functionIndex: match.index,
607
631
  functionHeaderEnd: match.index + match[0].length,
608
632
  });
609
- const associatedIds = new Set(region.match(CASE_ID) ?? []);
633
+ const associatedIds = new Set(caseIdsInText(region));
610
634
  const fromSymbol = symbolCaseId(symbol);
611
635
  if (fromSymbol)
612
636
  associatedIds.add(fromSymbol);
@@ -711,7 +735,8 @@ export async function collectBackendTestMappedPytestScripts(workspaceRoot) {
711
735
  function cleanCaseTitle(rawHeading, id) {
712
736
  return rawHeading
713
737
  .replace(/^##\s+/, "")
714
- .replace(new RegExp(`^${escapeRegExp(id)}\\s*(?:[||—–-]\\s*)?`), "")
738
+ // Strip either canonical or compatible two-digit heading ID.
739
+ .replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "")
715
740
  .trim() || id;
716
741
  }
717
742
  export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
@@ -755,7 +780,7 @@ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
755
780
  for (const match of content.matchAll(/^(\s*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
756
781
  const symbol = match[2];
757
782
  const region = testFunctionRegion({ source: content, functionIndex: match.index, functionHeaderEnd: match.index + match[0].length });
758
- const ids = new Set(region.match(CASE_ID) ?? []);
783
+ const ids = new Set(caseIdsInText(region));
759
784
  const fromSymbol = symbolCaseId(symbol);
760
785
  if (fromSymbol)
761
786
  ids.add(fromSymbol);
@@ -772,13 +797,13 @@ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
772
797
  return [...catalog.values()].sort((left, right) => left.id.localeCompare(right.id));
773
798
  }
774
799
  function junitCaseId(name) {
775
- return symbolCaseId(name) ?? name.match(CASE_ID)?.[0];
800
+ return symbolCaseId(name) ?? caseIdsInText(name)[0];
776
801
  }
777
802
  /** Prefer function-name Case ID; fall back to first BE-* in free text (docstring/log). */
778
803
  function extractCaseIdFromText(value) {
779
804
  if (!value?.trim())
780
805
  return undefined;
781
- return junitCaseId(value) ?? value.match(CASE_ID)?.[0];
806
+ return junitCaseId(value) ?? caseIdsInText(value)[0];
782
807
  }
783
808
  function resolveReportCaseId(result, catalog) {
784
809
  const fromName = extractCaseIdFromText(result.name);
@@ -1175,6 +1200,43 @@ function renderHttpCallCard(call) {
1175
1200
  : "";
1176
1201
  return `<div style="border:1px solid #eef0f3;border-radius:10px;padding:12px 14px;margin-top:10px;background:#fcfdff">${header}${requestBlock}${responseBlock}</div>`;
1177
1202
  }
1203
+ function analyzeBackendTestFailure(result) {
1204
+ const evidence = [result.message, result.details].filter(Boolean).join("\n");
1205
+ const statusPatterns = [
1206
+ { pattern: /实际(?:\s*HTTP)?\s*状态码\s*[::]?\s*(\d{3})[\s\S]{0,80}?预期(?:\s*HTTP)?\s*状态码?\s*[::]?\s*(\d{3})/i, actualGroup: 1, expectedGroup: 2 },
1207
+ { pattern: /预期(?:\s*HTTP)?\s*状态码\s*[::]?\s*(\d{3})[\s\S]{0,80}?实际(?:\s*HTTP)?\s*状态码?\s*[::]?\s*(\d{3})/i, actualGroup: 2, expectedGroup: 1 },
1208
+ { pattern: /assert\s+(\d{3})\s*==\s*(\d{3})/i, actualGroup: 1, expectedGroup: 2 },
1209
+ { pattern: /expected\s*[:=]?\s*(\d{3})[\s\S]{0,80}?(?:actual|received|got)\s*[:=]?\s*(\d{3})/i, actualGroup: 2, expectedGroup: 1 },
1210
+ ];
1211
+ for (const candidate of statusPatterns) {
1212
+ const match = evidence.match(candidate.pattern);
1213
+ if (match) {
1214
+ const actual = match[candidate.actualGroup];
1215
+ const expected = match[candidate.expectedGroup];
1216
+ if (actual && expected) {
1217
+ return `HTTP 状态码不符合预期:期望 ${expected},实际 ${actual}。建议核对接口错误码契约、异常映射与测试前置数据。`;
1218
+ }
1219
+ }
1220
+ }
1221
+ const contentTypeMatch = evidence.match(/assert\s+(['"])(.*?)\1\.startswith\(\s*(['"])application\/json\3\s*\)/i);
1222
+ if (contentTypeMatch) {
1223
+ const actual = contentTypeMatch[2];
1224
+ const actualLabel = actual ? `为 ${actual}` : "为空值";
1225
+ return `响应 Content-Type 不符合预期:期望以 application/json 开头,实际${actualLabel}。建议核对接口响应头设置。`;
1226
+ }
1227
+ const assertionEvidence = (result.details ?? "")
1228
+ .split(/\r?\n/)
1229
+ .map((line) => line.trim().replace(/^E\s+/, ""))
1230
+ .find((line) => /^assert\b/i.test(line));
1231
+ const rawMessage = /^\d+:\s*AssertionError\s*$/i.test(result.message?.trim() ?? "") && assertionEvidence
1232
+ ? assertionEvidence
1233
+ : result.message || assertionEvidence || humanStatus(result.status);
1234
+ const message = rawMessage.replace(/\s+/g, " ").trim().slice(0, 240);
1235
+ if (result.status === "error") {
1236
+ return `测试执行发生错误:${message}。建议优先检查 fixture、依赖导入、环境连接与运行时异常。`;
1237
+ }
1238
+ return `断言未满足:${message}。建议结合失败详情及请求/响应证据核对接口行为与用例预期。`;
1239
+ }
1178
1240
  export function renderBackendTestHtml(input) {
1179
1241
  const failed = input.parsed.failed + input.parsed.errors > 0;
1180
1242
  const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
@@ -1183,7 +1245,15 @@ export function renderBackendTestHtml(input) {
1183
1245
  const traceability = parseAdvisorySummary(input.traceabilitySummary);
1184
1246
  const failures = input.parsed.cases.filter((result) => result.status !== "passed");
1185
1247
  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: "✓" };
1186
- const caseCards = input.parsed.cases.map((result) => {
1248
+ const orderedCases = input.parsed.cases
1249
+ .map((result, index) => ({ result, index }))
1250
+ .sort((left, right) => {
1251
+ const leftFailed = left.result.status === "failure" || left.result.status === "error" ? 0 : 1;
1252
+ const rightFailed = right.result.status === "failure" || right.result.status === "error" ? 0 : 1;
1253
+ return leftFailed - rightFailed || left.index - right.index;
1254
+ })
1255
+ .map(({ result }) => result);
1256
+ const caseCards = orderedCases.map((result) => {
1187
1257
  const caseId = resolveReportCaseId(result, catalog);
1188
1258
  const item = caseId === "未关联" ? undefined : catalog.get(caseId);
1189
1259
  const io = requestResponseSummary(result);
@@ -1201,20 +1271,23 @@ export function renderBackendTestHtml(input) {
1201
1271
  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>`;
1202
1272
  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>`;
1203
1273
  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>`;
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>`;
1274
+ 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, input.caseValidationSummary)}${renderQualityCard("Markdown → pytest 追溯", traceability, input.traceabilitySummary)}</div></div>`;
1205
1275
  const failureOverview = failures.length > 0
1206
1276
  ? `<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) => {
1207
1277
  const caseId = resolveReportCaseId(result, catalog);
1208
1278
  const c = statusColor(result.status);
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>`;
1279
+ 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:4px"><strong>原始失败:</strong>${escapeHtml(result.message || humanStatus(result.status))}</div><div style="color:#475467;font-size:0.82rem;margin-top:5px"><strong style="color:#17365d">原因分析:</strong>${escapeHtml(analyzeBackendTestFailure(result))}</div></div>`;
1210
1280
  }).join("")}</div></div>`
1211
1281
  : "";
1212
1282
  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>`;
1213
1283
  }
1214
- function renderQualityCard(title, summary) {
1284
+ function renderQualityCard(title, summary, rawSummary) {
1215
1285
  const colors = summary.status === "PASS" ? { fg: "#067647", bg: "#ecfdf3" } : summary.status === "FAIL" ? { fg: "#b42318", bg: "#fef3f2" } : { fg: "#9a6700", bg: "#fffaeb" };
1216
1286
  const label = summary.status === "Unavailable" ? "不可用" : summary.status;
1217
- 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>`;
1287
+ const details = rawSummary?.trim()
1288
+ ? `<details style="margin-top:9px;padding-top:8px;border-top:1px solid #e3e8ef"><summary style="cursor:pointer;color:#315f93;font-size:0.78rem;font-weight:700">查看详情 · ${escapeHtml(title)}详情</summary><pre style="white-space:pre-wrap;overflow-wrap:anywhere;background:#f6f8fa;color:#1f2937;padding:10px 12px;border-radius:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.74rem;margin:7px 0 0;border:1px solid #eceef2;line-height:1.55">${escapeHtml(rawSummary.trim())}</pre></details>`
1289
+ : "";
1290
+ 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>${details}</div>`;
1218
1291
  }
1219
1292
  /**
1220
1293
  * Dump JaCoCo execution data over TCP from a JaCoCo tcpserver agent, convert
@@ -359,12 +359,36 @@ const HTML_ENTITY_MAP = {
359
359
  "&lt;": "<",
360
360
  "&gt;": ">",
361
361
  "&quot;": '"',
362
- "&#34;": '"',
363
- "&#39;": "'",
364
362
  "&apos;": "'",
365
363
  };
364
+ /** Decode one HTML entity layer. Safe for the outer data-jsonblob attribute. */
366
365
  function decodeHtmlEntities(value) {
367
- return value.replace(/&(?:amp|lt|gt|quot|#34|#39|apos);/g, (entity) => HTML_ENTITY_MAP[entity] ?? entity);
366
+ return value.replace(/&(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);/gi, (entity) => {
367
+ const named = HTML_ENTITY_MAP[entity.toLowerCase()];
368
+ if (named !== undefined)
369
+ return named;
370
+ const hex = entity.match(/^&#x([0-9a-f]+);$/i)?.[1];
371
+ const decimal = entity.match(/^&#(\d+);$/)?.[1];
372
+ const codePoint = hex
373
+ ? Number.parseInt(hex, 16)
374
+ : decimal
375
+ ? Number.parseInt(decimal, 10)
376
+ : Number.NaN;
377
+ return Number.isFinite(codePoint)
378
+ ? String.fromCodePoint(codePoint)
379
+ : entity;
380
+ });
381
+ }
382
+ /** Decode nested entities only after JSON parsing; bounded to avoid overwork. */
383
+ function decodeHtmlEntitiesDeep(value) {
384
+ let current = value;
385
+ for (let index = 0; index < 3; index += 1) {
386
+ const decoded = decodeHtmlEntities(current);
387
+ if (decoded === current)
388
+ break;
389
+ current = decoded;
390
+ }
391
+ return current;
368
392
  }
369
393
  function parseDurationLabelMs(raw) {
370
394
  if (!raw)
@@ -432,6 +456,7 @@ export function parsePytestHtmlReport(html) {
432
456
  const filePathFields = filePath ? { filePath } : {};
433
457
  const durationMs = parseDurationLabelMs(record.duration);
434
458
  const capturedLog = record.log ?? "";
459
+ const decodedCapturedLog = decodeHtmlEntitiesDeep(capturedLog);
435
460
  // pytest-html collapses captured stdout/stderr into a single `log` field
436
461
  // annotated with section markers. Preserve the whole log as stdout so the
437
462
  // HTTP_REQUEST/HTTP_RESPONSE lines stay reachable for the per-case card.
@@ -462,7 +487,7 @@ export function parsePytestHtmlReport(html) {
462
487
  durationMs,
463
488
  status: "failure",
464
489
  message: summary,
465
- details: capturedLog || summary,
490
+ details: decodedCapturedLog || summary,
466
491
  ...(stdout ? { stdout } : {}),
467
492
  ...(stderr ? { stderr } : {}),
468
493
  });
@@ -479,7 +504,7 @@ export function parsePytestHtmlReport(html) {
479
504
  durationMs,
480
505
  status: "error",
481
506
  message: summary,
482
- details: capturedLog || summary,
507
+ details: decodedCapturedLog || summary,
483
508
  ...(stdout ? { stdout } : {}),
484
509
  ...(stderr ? { stderr } : {}),
485
510
  });
@@ -509,7 +534,7 @@ export function parsePytestHtmlReport(html) {
509
534
  durationMs,
510
535
  status: "error",
511
536
  message: summary,
512
- details: capturedLog || summary,
537
+ details: decodedCapturedLog || summary,
513
538
  ...(stdout ? { stdout } : {}),
514
539
  ...(stderr ? { stderr } : {}),
515
540
  });
@@ -572,7 +597,7 @@ function splitPytestHtmlLogSections(log) {
572
597
  // pytest-html HTML-entity-escapes the captured log content (e.g. `&quot;`
573
598
  // for `"`). Decode entities first so downstream HTTP log parsers see the
574
599
  // real JSON/kv payload rather than escaped markup.
575
- const decoded = decodeHtmlEntities(log);
600
+ const decoded = decodeHtmlEntitiesDeep(log);
576
601
  // pytest-html interleaves captured stdout/stderr with section markers like
577
602
  // "----------------------------- Captured stdout call -----------------------------".
578
603
  // String.split includes capture-group matches in the result array, so the
@@ -601,16 +626,17 @@ function splitPytestHtmlLogSections(log) {
601
626
  function extractPytestHtmlFailureMessage(log) {
602
627
  if (!log)
603
628
  return "";
629
+ const decoded = decodeHtmlEntitiesDeep(log);
604
630
  // pytest-html failure logs contain assertion lines prefixed with "E " and a
605
631
  // trailing location line like "test_x.py:N: AssertionError". Prefer the
606
632
  // explicit AssertionError/Error line; fall back to the last "E " line.
607
- const assertionLine = log
633
+ const assertionLine = decoded
608
634
  .split(/\r?\n/)
609
635
  .map((line) => line.trim())
610
636
  .find((line) => /:\s*AssertionError/.test(line));
611
637
  if (assertionLine)
612
638
  return assertionLine.replace(/^.*?:\s*/, "");
613
- const eLines = log
639
+ const eLines = decoded
614
640
  .split(/\r?\n/)
615
641
  .filter((line) => /^E\s+/.test(line))
616
642
  .map((line) => line.replace(/^E\s+/, "").trim());
@@ -0,0 +1,71 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ async function exists(file) {
4
+ try {
5
+ return (await stat(file)).isFile();
6
+ }
7
+ catch {
8
+ return false;
9
+ }
10
+ }
11
+ export async function validateFrontendCaseChecklist(input) {
12
+ const root = path.join(input.workspaceRoot, "testcase/frontend/cases");
13
+ const draft = path.join(root, "manifest.draft.json");
14
+ const final = path.join(root, "manifest.json");
15
+ const manifestPath = await exists(draft) ? draft : await exists(final) ? final : "";
16
+ if (!manifestPath)
17
+ throw new Error("checklist: missing manifest.draft.json or manifest.json");
18
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
19
+ if (!Array.isArray(manifest.cases) || manifest.cases.length === 0)
20
+ throw new Error("checklist: empty cases");
21
+ const declaredAc = new Set(input.declaredAcIds ?? []);
22
+ const issues = [];
23
+ const caseIdRe = /^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;
24
+ const acIdRe = /^AC(?:-[A-Z0-9]+)+$/i;
25
+ const openRe = /playwright-cli\s+open\s+--browser=chrome\s+--headed\s+https?:\/\/\S+/i;
26
+ const productionHostRe = /(^|[.-])(prod|production)([.-]|$)/i;
27
+ for (const raw of manifest.cases) {
28
+ const item = raw;
29
+ const id = typeof item?.caseId === "string" ? item.caseId : "?";
30
+ if (typeof item?.caseId !== "string" || !caseIdRe.test(item.caseId))
31
+ issues.push({ ruleId: "case-id-shape", caseId: id, detail: "caseId must be FE-<FEATURE>-<NNN>-<dimension>, never AC-FE-*" });
32
+ if (typeof item?.caseId === "string" && /^AC-/i.test(item.caseId))
33
+ issues.push({ ruleId: "case-id-is-ac", caseId: id, detail: "caseId must not be an acceptance id" });
34
+ const casePath = typeof item?.casePath === "string" ? item.casePath : "";
35
+ const expectedPath = typeof item?.caseId === "string" ? `testcase/frontend/cases/${item.caseId}.md` : "";
36
+ const absolute = path.resolve(input.workspaceRoot, casePath);
37
+ const relative = path.relative(input.workspaceRoot, absolute);
38
+ if (!casePath || relative.startsWith("..") || path.isAbsolute(relative) || !(await exists(absolute))) {
39
+ issues.push({ ruleId: "case-file-missing", caseId: id, detail: casePath || "missing casePath" });
40
+ continue;
41
+ }
42
+ if (expectedPath && casePath.replaceAll("\\", "/") !== expectedPath)
43
+ issues.push({ ruleId: "case-path-mismatch", caseId: id, detail: `${casePath} must equal ${expectedPath}` });
44
+ const body = await readFile(absolute, "utf8");
45
+ if (!openRe.test(body))
46
+ issues.push({ ruleId: "open-prefix", caseId: id, detail: "missing playwright-cli open --browser=chrome --headed <absolute-url>" });
47
+ const match = body.match(/playwright-cli\s+open\s+--browser=chrome\s+--headed\s+(https?:\/\/\S+)/i);
48
+ if (match) {
49
+ try {
50
+ const url = new URL(match[1].replace(/[)\]},.\"'`]+$/, ""));
51
+ if (productionHostRe.test(url.hostname))
52
+ issues.push({ ruleId: "production-url", caseId: id, detail: match[1] });
53
+ }
54
+ catch {
55
+ issues.push({ ruleId: "production-url", caseId: id, detail: match[1] });
56
+ }
57
+ }
58
+ if (!Array.isArray(item?.acIds) || item.acIds.length === 0) {
59
+ issues.push({ ruleId: "ac-mapping", caseId: id, detail: "acIds required" });
60
+ }
61
+ else {
62
+ for (const ac of item.acIds) {
63
+ if (typeof ac !== "string" || !acIdRe.test(ac))
64
+ issues.push({ ruleId: "ac-id-shape", caseId: id, detail: String(ac) });
65
+ else if (declaredAc.size > 0 && !declaredAc.has(ac))
66
+ issues.push({ ruleId: "unknown-ac", caseId: id, detail: `${ac} not in sourceBinding` });
67
+ }
68
+ }
69
+ }
70
+ return { caseCount: manifest.cases.length, manifestPath, issues };
71
+ }