@tea-agent/loop-agent 0.29.0 → 0.29.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.
@@ -7,11 +7,56 @@ function escapeHtml(value) {
7
7
  function statusLabel(status) {
8
8
  return status === "passed" ? "通过" : status === "failed" ? "失败" : "阻塞";
9
9
  }
10
+ function statusColor(status) {
11
+ return status === "passed"
12
+ ? { fg: "#067647", bg: "#ecfdf3", border: "#abefc6" }
13
+ : status === "failed"
14
+ ? { fg: "#b42318", bg: "#fef3f2", border: "#fda29b" }
15
+ : { fg: "#946200", bg: "#fffaeb", border: "#fedf89" };
16
+ }
10
17
  function listMarkdown(items) {
11
18
  return items.length ? items.map((item, index) => `${index + 1}. ${item}`).join("\n") : "- 无";
12
19
  }
13
20
  function listHtml(items) {
14
- return items.length ? `<ol>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ol>` : "<p>无</p>";
21
+ return items.length ? `<ol>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ol>` : `<p class="muted">无</p>`;
22
+ }
23
+ function writeMetric(label, value, color = "#172033") {
24
+ return `<div class="metric"><span>${escapeHtml(label)}</span><strong style="color:${color}">${escapeHtml(value)}</strong></div>`;
25
+ }
26
+ function renderCaseCard(item) {
27
+ const colors = statusColor(item.status);
28
+ const rerun = typeof item.rerunAttempt === "number" &&
29
+ item.rerunAttempt > 0
30
+ ? `<span class="badge" style="color:#175cd3;background:#eff8ff">重跑 ${item.rerunAttempt}</span>`
31
+ : "";
32
+ const error = item.status === "passed"
33
+ ? ""
34
+ : `<div class="error-box"><strong>原因分析</strong><p>${escapeHtml(item.errorAnalysis ?? item.blockedReason ?? "用例未能完成执行。")}</p></div>`;
35
+ const execution = item.status === "passed"
36
+ ? ""
37
+ : `<div class="error-box"><strong>执行记录</strong><p>${escapeHtml(item.executionSummary ??
38
+ item.errorAnalysis ??
39
+ item.blockedReason ??
40
+ "见 evidence 目录 execution.md / case-result.json")}</p></div>`;
41
+ const testPoints = item.testPoints;
42
+ const testPointsHtml = Array.isArray(testPoints) && testPoints.length
43
+ ? `<div><h3>测试点</h3>${listHtml(testPoints)}</div>`
44
+ : "";
45
+ return `<details class="case-card" data-status="${item.status}" ${item.status !== "passed" ? "open" : ""}>
46
+ <summary><span class="case-id">${escapeHtml(item.caseId)}</span><span class="case-title">${escapeHtml(item.caseContent.purpose || "未提供测试目的")}</span><span class="badge" style="color:${colors.fg};background:${colors.bg}">${statusLabel(item.status)}</span>${rerun}<span class="chevron">▾</span></summary>
47
+ <div class="case-body">
48
+ <div class="case-meta"><span>验收标准</span><strong>${item.acIds.map(escapeHtml).join("、") || "—"}</strong></div>
49
+ <div class="case-grid">
50
+ <div><h3>测试目的</h3><p>${escapeHtml(item.caseContent.purpose)}</p></div>
51
+ ${testPointsHtml}
52
+ <div><h3>前置条件</h3>${listHtml(item.caseContent.preconditions)}</div>
53
+ <div><h3>测试步骤</h3>${listHtml(item.caseContent.steps)}</div>
54
+ <div><h3>预期结果</h3>${listHtml(item.caseContent.expectedResults)}</div>
55
+ </div>
56
+ ${error}
57
+ ${execution}
58
+ </div>
59
+ </details>`;
15
60
  }
16
61
  async function writePairAtomic(markdownPath, markdown, htmlPath, html) {
17
62
  await mkdir(path.dirname(markdownPath), { recursive: true });
@@ -36,42 +81,79 @@ export async function renderFrontendTestHtmlReport(input) {
36
81
  const markdownPath = path.join(outputDir, "frontend-test-report.md");
37
82
  const htmlPath = path.join(outputDir, "frontend-test-report.html");
38
83
  const outcomeLabel = result.outcome === "passed" ? "测试通过" : result.outcome === "failed" ? "测试失败" : "测试未完成";
84
+ const failedCount = result.totals.failed + result.totals.blocked;
85
+ const passRate = result.totals.cases > 0 ? (result.totals.passed / result.totals.cases) * 100 : 0;
86
+ const coverageRate = result.sourceBinding.requirementIds.length > 0
87
+ ? (result.acceptanceCoverage.covered.length / result.sourceBinding.requirementIds.length) * 100
88
+ : 0;
89
+ const outcomeColors = result.outcome === "passed" ? statusColor("passed") : result.outcome === "failed" ? statusColor("failed") : statusColor("blocked");
39
90
  const markdownCases = result.cases.map((item) => [
40
91
  `## ${item.caseId}`,
41
92
  "",
42
93
  `- 执行结果:${statusLabel(item.status)}`,
94
+ `- 验收标准:${item.acIds.join("、")}`,
43
95
  "",
44
- "### 测试目的",
45
- "",
46
- item.caseContent.purpose,
47
- "",
48
- "### 前置条件",
49
- "",
50
- listMarkdown(item.caseContent.preconditions),
51
- "",
52
- "### 操作步骤",
53
- "",
54
- listMarkdown(item.caseContent.steps),
55
- "",
56
- "### 预期结果",
57
- "",
58
- listMarkdown(item.caseContent.expectedResults),
59
- ...(item.status === "passed" ? [] : ["", "### 错误分析", "", item.errorAnalysis ?? `用例因 ${item.blockedReason ?? "未知原因"} 未能完成。`]),
96
+ "### 测试目的", "", item.caseContent.purpose,
97
+ "", "### 前置条件", "", listMarkdown(item.caseContent.preconditions),
98
+ "", "### 操作步骤", "", listMarkdown(item.caseContent.steps),
99
+ "", "### 预期结果", "", listMarkdown(item.caseContent.expectedResults),
100
+ ...(item.status === "passed" ? [] : ["", "### 错误分析", "", item.errorAnalysis ?? item.blockedReason ?? "用例未能完成执行。"]),
60
101
  ].join("\n")).join("\n\n");
61
102
  const markdown = [
62
- "# 前端功能测试报告",
63
- "",
103
+ "# 前端功能测试报告", "",
64
104
  `- 测试结论:${outcomeLabel}`,
65
105
  `- 用例总数:${result.totals.cases}`,
66
106
  `- 通过:${result.totals.passed}`,
67
107
  `- 失败:${result.totals.failed}`,
68
108
  `- 阻塞:${result.totals.blocked}`,
69
- "",
70
- markdownCases,
71
- "",
109
+ `- AC 覆盖:${result.acceptanceCoverage.covered.length}/${result.sourceBinding.requirementIds.length}`,
110
+ "", markdownCases, "",
72
111
  ].join("\n");
73
- const htmlCases = result.cases.map((item) => `<section class="case ${item.status}"><header><h2>${escapeHtml(item.caseId)}</h2><span class="status">${statusLabel(item.status)}</span></header><h3>测试目的</h3><p>${escapeHtml(item.caseContent.purpose)}</p><h3>前置条件</h3>${listHtml(item.caseContent.preconditions)}<h3>操作步骤</h3>${listHtml(item.caseContent.steps)}<h3>预期结果</h3>${listHtml(item.caseContent.expectedResults)}${item.status === "passed" ? "" : `<h3>错误分析</h3><p class="error">${escapeHtml(item.errorAnalysis ?? `用例因 ${item.blockedReason ?? "未知原因"} 未能完成。`)}</p>`}</section>`).join("");
74
- const html = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>前端功能测试报告</title><style>body{font:16px system-ui,"Microsoft YaHei",sans-serif;margin:0;background:#f5f7fb;color:#172033}main{max-width:1100px;margin:auto;padding:32px}.summary,.case{background:#fff;border-radius:14px;padding:22px;margin:16px 0;box-shadow:0 6px 24px rgba(16,24,40,.07)}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.metric{background:#f8fafc;padding:14px;border-radius:10px}.case header{display:flex;justify-content:space-between;gap:16px}.status{font-weight:700}.passed .status{color:#067647}.failed .status,.error{color:#b42318}.blocked .status{color:#946200}li{margin:.45rem 0}@media(max-width:700px){.metrics{grid-template-columns:1fr 1fr}}</style></head><body><main><h1>前端功能测试报告</h1><section class="summary"><h2>${outcomeLabel}</h2><div class="metrics"><div class="metric">用例总数<br><strong>${result.totals.cases}</strong></div><div class="metric">通过<br><strong>${result.totals.passed}</strong></div><div class="metric">失败<br><strong>${result.totals.failed}</strong></div><div class="metric">阻塞<br><strong>${result.totals.blocked}</strong></div></div></section>${htmlCases}</main></body></html>`;
112
+ const caseCards = result.cases.map(renderCaseCard).join("");
113
+ const missingAc = result.acceptanceCoverage.missing.length
114
+ ? `<div class="finding-list"><strong>未覆盖 AC</strong>${listHtml(result.acceptanceCoverage.missing)}</div>`
115
+ : `<div class="success-note">全部声明的 AC 均已覆盖。</div>`;
116
+ const failureOverview = failedCount > 0
117
+ ? `<section class="panel"><div class="section-heading"><h2>失败概览</h2><span>${failedCount} 条未通过用例</span></div><div class="failure-list">${result.cases.filter((item) => item.status !== "passed").map((item) => `<div class="failure-row"><span class="case-id">${escapeHtml(item.caseId)}</span><span>${escapeHtml(item.errorAnalysis ?? item.blockedReason ?? "用例未能完成执行。")}</span></div>`).join("")}</div></section>`
118
+ : "";
119
+ const html = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>前端功能测试报告</title><style>
120
+ body{margin:0;background:radial-gradient(circle at 8% 0,#edf4ff 0,transparent 36rem),#f5f7fb;color:#17243b;font:15px/1.6 Inter,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif}main{max-width:1180px;margin:0 auto;padding:42px 28px 72px}.hero{padding:30px 34px;border:1px solid #294b78;border-radius:24px;background:linear-gradient(125deg,#102849 0%,#173d6d 57%,#245b91 100%);box-shadow:0 18px 40px rgba(16,40,73,.18);display:flex;align-items:flex-start;justify-content:space-between;gap:28px}.eyebrow{color:#9fc6ee;font-size:11px;font-weight:800;letter-spacing:.2em}.hero h1{margin:10px 0 8px;color:#f7fbff;font-size:clamp(28px,3.8vw,44px);line-height:1.1}.hero p{margin:0;color:#bdd2e9;font-size:13px}.decision{min-width:190px;padding:15px 17px;border:1px solid ${outcomeColors.border};border-radius:16px;background:${outcomeColors.bg};color:${outcomeColors.fg}}.decision strong{display:block;font-size:17px}.decision span{display:block;margin-top:4px;font-size:12px}.panel{background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:18px}.metrics{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-top:18px}.metric{position:relative;min-height:92px;padding:17px 18px;background:#fff;border:1px solid #e1e8f2;border-radius:13px}.metric:before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:#4775ef;border-radius:13px 13px 0 0}.metric span{display:block;color:#748198;font-size:12px}.metric strong{display:block;margin-top:12px;font-size:28px;line-height:1;font-weight:800}.section-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px}.section-heading h2{margin:0;color:#17365d;font-size:19px}.section-heading span{color:#718097;font-size:12px}.signal-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.signal{padding:14px;border:1px solid #e4e9f1;border-radius:11px;background:#fbfcfe}.signal header{display:flex;justify-content:space-between;color:#2a3c5a;font-size:12px}.bar{height:7px;margin:10px 0 6px;background:#edf1f6;border-radius:99px;overflow:hidden}.bar i{display:block;height:100%;border-radius:inherit}.signal small{color:#718097;font-size:11px}.case-card{border:1px solid #e4e9f1;border-radius:13px;background:#fff;overflow:hidden;margin-top:10px}.case-card summary{display:flex;align-items:center;gap:12px;padding:14px 16px;cursor:pointer;list-style:none}.case-card summary::-webkit-details-marker{display:none}.case-id{color:#2b3a55;font:700 12px ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap}.case-title{flex:1;min-width:0;color:#172033;font-weight:650;overflow:hidden;text-overflow:ellipsis}.badge{padding:3px 10px;border-radius:999px;font-size:11px;font-weight:800;white-space:nowrap}.chevron{color:#aab2bf}.case-body{padding:3px 16px 17px;border-top:1px solid #f0f3f7}.case-meta{margin-top:12px;padding:9px 11px;background:#f6f8fa;border-radius:8px;color:#667085;font-size:11px}.case-meta strong{margin-left:8px;color:#344054}.case-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:13px}.case-grid>div{padding:11px 12px;border:1px solid #edf0f4;border-radius:9px;background:#fbfcfe}.case-grid h3{margin:0 0 5px;color:#667085;font-size:11px}.case-grid p,.case-grid ol{margin:0;color:#475467;font-size:13px}.case-grid ol{padding-left:20px}.muted{color:#98a2b3!important}.error-box{margin-top:13px;padding:11px 13px;border:1px solid #fda29b;border-radius:9px;background:#fef3f2;color:#b42318}.error-box strong{font-size:12px}.error-box p{margin:4px 0 0;white-space:pre-wrap;font-size:13px}.failure-list{display:grid;gap:9px}.failure-row{display:flex;align-items:baseline;gap:12px;padding:10px 12px;border-left:4px solid #d34661;border-radius:8px;background:#fff8f9;color:#475467;font-size:13px}.success-note{padding:11px 13px;border-radius:9px;background:#ecfdf3;color:#067647;font-size:13px}.finding-list{padding:11px 13px;border-radius:9px;background:#fffaeb;color:#946200;font-size:13px}.finding-list ol{margin:5px 0 0;padding-left:20px}@media(max-width:850px){.hero{display:block}.decision{margin-top:20px;min-width:0}.metrics{grid-template-columns:repeat(2,1fr)}.signal-grid,.case-grid{grid-template-columns:1fr}}@media(max-width:520px){main{padding:22px 14px 42px}.metrics{grid-template-columns:1fr}.case-card summary{flex-wrap:wrap}.case-title{order:3;flex-basis:100%}}
121
+
122
+ .filter-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:12px 0 16px}
123
+ .filter-label{font-size:13px;color:#475467}
124
+ .filter-btn{border:1px solid #d0d5dd;background:#fff;border-radius:999px;padding:6px 12px;cursor:pointer;font-size:13px}
125
+ .filter-btn.active{background:#eff8ff;border-color:#84caff;color:#175cd3}
126
+ .case-card[hidden]{display:none !important}
127
+ </style></head><body><main>
128
+ <header class="hero"><div><div class="eyebrow">FRONTEND TEST · EXECUTION VIEW</div><h1>前端功能测试报告</h1><p>基于本轮 frontend-test DAG 的真实用例结果生成;用例默认折叠,点击任意用例查看测试内容与失败分析。</p></div><div class="decision"><strong>${outcomeLabel}</strong><span>${failedCount > 0 ? `${failedCount} 条用例未通过` : `${result.totals.passed} 条用例全部执行成功`}</span></div></header>
129
+ <section class="filter-bar" id="status-filter">
130
+ <span class="filter-label">按状态过滤</span>
131
+ <button type="button" class="filter-btn active" data-filter="all">全部</button>
132
+ <button type="button" class="filter-btn" data-filter="passed">通过</button>
133
+ <button type="button" class="filter-btn" data-filter="failed">失败</button>
134
+ <button type="button" class="filter-btn" data-filter="blocked">阻塞</button>
135
+ </section>
136
+ <section class="metrics">${writeMetric("用例总数", result.totals.cases)}${writeMetric("通过", result.totals.passed, "#067647")}${writeMetric("失败", result.totals.failed, result.totals.failed ? "#b42318" : "#172033")}${writeMetric("阻塞", result.totals.blocked, result.totals.blocked ? "#946200" : "#172033")}${writeMetric("通过率", `${passRate.toFixed(2)}%`, passRate === 100 ? "#067647" : "#b42318")}</section>
137
+ <section class="panel"><div class="section-heading"><h2>质量信号</h2><span>需求覆盖与执行结果</span></div><div class="signal-grid"><div class="signal"><header><span>AC 验收覆盖</span><strong>${coverageRate.toFixed(2)}%</strong></header><div class="bar"><i style="width:${Math.min(coverageRate, 100)}%;background:${coverageRate >= 100 ? "#15815c" : "#4775ef"}"></i></div><small>${result.acceptanceCoverage.covered.length} / ${result.sourceBinding.requirementIds.length} · 目标 100%</small></div><div class="signal"><header><span>集成模式</span><strong>${result.integrationMode === "real" ? "真实联调" : "未联调"}</strong></header><div class="bar"><i style="width:${result.integrationMode === "real" ? 100 : 0}%;background:#15815c"></i></div><small>${result.integrationMode === "real" ? "已执行真实后端调用" : "本轮未证明真实后端已联通"}</small></div></div>${missingAc}</section>
138
+ ${failureOverview}<section class="panel"><div class="section-heading"><h2>用例执行明细</h2><span>共 ${result.cases.length} 条 · 点击展开</span></div>${caseCards || "<div class=muted>未发现可展示的用例。</div>"}</section>
139
+ </main>
140
+ <script>
141
+ (function(){
142
+ var bar=document.getElementById('status-filter');
143
+ if(!bar) return;
144
+ bar.addEventListener('click', function(ev){
145
+ var btn=ev.target.closest('[data-filter]');
146
+ if(!btn) return;
147
+ var filter=btn.getAttribute('data-filter')||'all';
148
+ bar.querySelectorAll('.filter-btn').forEach(function(b){ b.classList.toggle('active', b===btn); });
149
+ document.querySelectorAll('.case-card[data-status]').forEach(function(card){
150
+ var st=card.getAttribute('data-status');
151
+ card.hidden = !(filter==='all' || st===filter);
152
+ });
153
+ });
154
+ })();
155
+ </script>
156
+ </body></html>`;
75
157
  await writePairAtomic(markdownPath, markdown, htmlPath, html);
76
158
  return { markdownPath, htmlPath, outcome: result.outcome, caseCount: result.cases.length };
77
159
  }
@@ -34,6 +34,9 @@ export const frontendTestResultContractSchema = z.object({
34
34
  status: caseStatusSchema,
35
35
  evidence: z.array(z.object({ path: safeRelativePathSchema, sha256: sha256Schema }).strict()),
36
36
  blockedReason: z.string().min(1).optional(),
37
+ rerunAttempt: z.number().int().nonnegative().optional(),
38
+ testPoints: z.array(z.string()).optional(),
39
+ executionSummary: z.string().optional(),
37
40
  caseContent: z.object({
38
41
  purpose: z.string(),
39
42
  preconditions: z.array(z.string()),