@tea-agent/loop-agent 0.29.0 → 0.29.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.
@@ -6,6 +6,9 @@ const LIST_ITEM = /^\s*(?:[-+*]|\d+[.)])\s+(.+)$/;
6
6
  const INDENTED_CODE = /^(?: {4,}|\t+)(\S.*)$/;
7
7
  const EXPLICIT_COMMAND = /^(?:(?:shell|terminal)(?:\s+command)?|command|run command|execute command)\s*:\s*(.+)$/i;
8
8
  const BLOCKED_REASON_PREFIX = /^(?:(?:blocked|forbidden|reject(?:ed)?|disallow(?:ed)?|prohibited)(?:\s+reason)?\s*(?::|\bbecause\b)|(?:do not|must not|never)\s+(?:run|execute|use)\b)/i;
9
+ /** Standalone blockedReason / environment tokens (not executable commands). */
10
+ const BLOCKED_REASON_TOKEN = /^(?:playwright-cli-unavailable|frontend-base-url-unreachable|curl-unavailable|browser-command-capability-unavailable|browser-command-evidence-missing|token-budget-exhausted|current-user-data-unavailable|data-ownership-unverifiable|safe-test-data-setup-unavailable|invalid-evidence-shape)(?:\s|$|[.,;:)`'"\]])/i;
11
+ const BLOCKED_PROSE_HINT = /(?:blockedReason|blocked\s*reason|environmentProbe|evidenceDir|\bunavailable\b|不可用|写\s*blocked|写入\s*blocked|伪命令|自然语言|元数据|fail-closed|探测失败)/i;
9
12
  const IMPERATIVE_COMMAND = /^(?:(?:run|execute|use)(?:\s+(?:the\s+)?(?:(?:shell|terminal)\s+)?command)?|in\s+(?:the\s+)?(?:shell|terminal|console)\s*,?\s*(?:run|execute|use))\s*:?\s+(.+)$/i;
10
13
  const INLINE_CODE_STEP = /^`([^`\r\n]+)`[.!?]?$/;
11
14
  const AUTOMATION_EXECUTABLE = /^(?:playwright-cli\b|playwright\b|@playwright\/test\b|npx\b|npm\b|pnpm\b|yarn\b|bunx?\b|node(?:js)?\b|python(?:3)?\b|bash\b|sh\b|zsh\b|fish\b|powershell\b|pwsh\b|cmd(?:\.exe)?\b|cypress\b|selenium\b|webdriverio\b|chromedriver\b|google-chrome\b|chrome\b|firefox\b|curl\b|wget\b)/i;
@@ -31,7 +34,53 @@ function isExecutableFence(language) {
31
34
  return COMMAND_FENCE_LANGUAGE.test(language.trim());
32
35
  }
33
36
  function isBlockedReason(value) {
34
- return BLOCKED_REASON_PREFIX.test(value.trim());
37
+ const trimmed = value.trim();
38
+ if (!trimmed)
39
+ return false;
40
+ if (BLOCKED_REASON_PREFIX.test(trimmed))
41
+ return true;
42
+ // Bare reason enum / metadata line (BugPilot blocked sections).
43
+ if (BLOCKED_REASON_TOKEN.test(trimmed.replace(/^`+|`+$/g, "")))
44
+ return true;
45
+ if (/^blockedReason\s*[:=]/i.test(trimmed))
46
+ return true;
47
+ return false;
48
+ }
49
+ /**
50
+ * Non-executable prose that mentions playwright-cli / reason tokens for blocked
51
+ * paths (e.g. "playwright-cli 不可用时… blockedReason: …"). Must not enter the
52
+ * command allowlist gate.
53
+ */
54
+ function isNonExecutableBlockedProse(value) {
55
+ const trimmed = value.trim();
56
+ if (!trimmed)
57
+ return false;
58
+ if (isBlockedReason(trimmed))
59
+ return true;
60
+ const normalized = normalizeCommand(trimmed);
61
+ const bare = normalized.replace(/^`+|`+$/g, "").trim();
62
+ if (BLOCKED_REASON_TOKEN.test(bare))
63
+ return true;
64
+ // "playwright-cli 不可用…" / "playwright-cli unavailable…" documentation.
65
+ if (/^playwright-cli\b/i.test(normalized) &&
66
+ BLOCKED_PROSE_HINT.test(normalized) &&
67
+ !/^playwright-cli\s+[a-z][a-z0-9-]*\b/i.test(normalized)) {
68
+ return true;
69
+ }
70
+ const pw = normalized.match(/^playwright-cli\s+(\S+)([\s\S]*)$/i);
71
+ if (pw) {
72
+ const first = pw[1].replace(/^[`'"]+|[`'":,,。→]+$/g, "");
73
+ if (!isPlaywrightCliCommand(first.toLowerCase())) {
74
+ // Non-verb after playwright-cli in prose (e.g. 不可用 / unavailable).
75
+ return /[\u4e00-\u9fff]/.test(first) || BLOCKED_PROSE_HINT.test(normalized) || !/^[a-z][a-z0-9-]*$/i.test(first);
76
+ }
77
+ }
78
+ // Reason token embedded without looking like a real CLI invocation.
79
+ if (BLOCKED_PROSE_HINT.test(trimmed) &&
80
+ !/^playwright-cli\s+[a-z][a-z0-9-]*(\s+--|\s+https?:|\s*$)/i.test(normalized)) {
81
+ return true;
82
+ }
83
+ return false;
35
84
  }
36
85
  function stripLeadingCommandWrappers(command) {
37
86
  let remaining = command.trim();
@@ -64,17 +113,30 @@ function isCommandLikeExecutable(command) {
64
113
  }
65
114
  function executableListStep(value) {
66
115
  const trimmed = value.trim();
116
+ if (isNonExecutableBlockedProse(trimmed))
117
+ return null;
67
118
  const explicit = trimmed.match(EXPLICIT_COMMAND);
68
- if (explicit)
69
- return isBlockedReason(explicit[1]) ? null : normalizeCommand(explicit[1]);
119
+ if (explicit) {
120
+ if (isBlockedReason(explicit[1]) || isNonExecutableBlockedProse(explicit[1]))
121
+ return null;
122
+ return normalizeCommand(explicit[1]);
123
+ }
70
124
  const imperative = trimmed.match(IMPERATIVE_COMMAND);
71
- if (imperative)
125
+ if (imperative) {
126
+ if (isNonExecutableBlockedProse(imperative[1]))
127
+ return null;
72
128
  return normalizeCommand(imperative[1]);
129
+ }
73
130
  const inlineCode = trimmed.match(INLINE_CODE_STEP);
74
- if (inlineCode)
131
+ if (inlineCode) {
132
+ if (isNonExecutableBlockedProse(inlineCode[1]))
133
+ return null;
75
134
  return normalizeCommand(inlineCode[1]);
135
+ }
76
136
  const prompted = /^[$>]\s*\S/.test(trimmed);
77
137
  const normalized = normalizeCommand(trimmed);
138
+ if (isNonExecutableBlockedProse(normalized))
139
+ return null;
78
140
  if (prompted || isCommandLikeExecutable(normalized))
79
141
  return normalized;
80
142
  return null;
@@ -127,12 +189,15 @@ function extractExecutableInstructions(markdown) {
127
189
  if (fenceLanguage !== null) {
128
190
  if (!isExecutableFence(fenceLanguage) || /^(?:#|\/\/)/.test(trimmed))
129
191
  continue;
130
- instructions.push({ command: normalizeCommand(trimmed), lineNumber });
192
+ // Fenced blocks are strict: only skip pure reason-token / blockedReason lines.
193
+ if (isBlockedReason(trimmed) || BLOCKED_REASON_TOKEN.test(normalizeCommand(trimmed).replace(/^`+|`+$/g, "")))
194
+ continue;
195
+ instructions.push({ command: normalizeCommand(trimmed), lineNumber, fromFence: true });
131
196
  continue;
132
197
  }
133
198
  const listed = line.match(LIST_ITEM);
134
199
  if (listed) {
135
- if (isBlockedReason(listed[1]))
200
+ if (isBlockedReason(listed[1]) || isNonExecutableBlockedProse(listed[1]))
136
201
  continue;
137
202
  const command = executableListStep(listed[1]);
138
203
  if (command !== null)
@@ -141,17 +206,20 @@ function extractExecutableInstructions(markdown) {
141
206
  }
142
207
  const indented = line.match(INDENTED_CODE);
143
208
  if (indented) {
144
- if (isBlockedReason(indented[1]))
209
+ if (isBlockedReason(indented[1]) || isNonExecutableBlockedProse(indented[1]))
145
210
  continue;
146
211
  const explicitIndented = indented[1].match(EXPLICIT_COMMAND);
147
- if (explicitIndented && isBlockedReason(explicitIndented[1]))
212
+ if (explicitIndented && (isBlockedReason(explicitIndented[1]) || isNonExecutableBlockedProse(explicitIndented[1])))
213
+ continue;
214
+ const fromStep = executableListStep(indented[1]);
215
+ if (fromStep === null)
148
216
  continue;
149
- instructions.push({ command: executableListStep(indented[1]) ?? normalizeCommand(indented[1]), lineNumber });
217
+ instructions.push({ command: fromStep, lineNumber });
150
218
  continue;
151
219
  }
152
220
  const explicit = trimmed.match(EXPLICIT_COMMAND);
153
221
  if (explicit) {
154
- if (isBlockedReason(explicit[1]))
222
+ if (isBlockedReason(explicit[1]) || isNonExecutableBlockedProse(explicit[1]))
155
223
  continue;
156
224
  instructions.push({ command: normalizeCommand(explicit[1]), lineNumber });
157
225
  continue;
@@ -163,9 +231,16 @@ function extractExecutableInstructions(markdown) {
163
231
  return instructions;
164
232
  }
165
233
  function commandGateIssue(input) {
234
+ // Outside fences, blocked/unavailable prose must never become a gate failure.
235
+ if (!input.instruction.fromFence && isNonExecutableBlockedProse(input.instruction.command)) {
236
+ return null;
237
+ }
238
+ if (input.instruction.fromFence && isBlockedReason(input.instruction.command)) {
239
+ return null;
240
+ }
166
241
  const commandMatch = input.instruction.command.match(/^playwright-cli\s+([^\s`]+)/i);
167
242
  if (commandMatch) {
168
- const command = commandMatch[1].toLowerCase();
243
+ const command = commandMatch[1].toLowerCase().replace(/^[`'"]+|[`'":,,。→]+$/g, "");
169
244
  if (isPlaywrightCliCommand(command) && !hasShellControl(input.instruction.command))
170
245
  return null;
171
246
  if (isPlaywrightCliCommand(command)) {
@@ -178,6 +253,10 @@ function commandGateIssue(input) {
178
253
  detail: "shell control or additional executable fragments are not allowed after playwright-cli commands",
179
254
  };
180
255
  }
256
+ // Fenced bad verbs stay rejected; unfenced non-verbs already filtered as prose.
257
+ if (!input.instruction.fromFence && isNonExecutableBlockedProse(input.instruction.command)) {
258
+ return null;
259
+ }
181
260
  return {
182
261
  ruleId: "playwright-cli-command-not-allowed",
183
262
  caseId: input.caseId,
@@ -210,7 +289,7 @@ export async function validateFrontendCaseChecklist(input) {
210
289
  const issues = [];
211
290
  const caseIdRe = /^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;
212
291
  const acIdRe = /^AC(?:-[A-Z0-9]+)+$/i;
213
- const openRe = /playwright-cli\s+open\s+--browser=chrome\s+--headed\s+https?:\/\/\S+/i;
292
+ const openRe = /playwright-cli\s+open\s+--browser=chrome\s+--headless\s+https?:\/\/\S+/i;
214
293
  const productionHostRe = /(^|[.-])(prod|production)([.-]|$)/i;
215
294
  for (const raw of manifest.cases) {
216
295
  const item = raw;
@@ -231,8 +310,8 @@ export async function validateFrontendCaseChecklist(input) {
231
310
  issues.push({ ruleId: "case-path-mismatch", caseId: id, casePath, detail: `${casePath} must equal ${expectedPath}` });
232
311
  const body = await readFile(absolute, "utf8");
233
312
  if (!openRe.test(body))
234
- issues.push({ ruleId: "open-prefix", caseId: id, casePath, detail: "missing playwright-cli open --browser=chrome --headed <absolute-url>" });
235
- const match = body.match(/playwright-cli\s+open\s+--browser=chrome\s+--headed\s+(https?:\/\/\S+)/i);
313
+ issues.push({ ruleId: "open-prefix", caseId: id, casePath, detail: "missing playwright-cli open --browser=chrome --headless <absolute-url>" });
314
+ const match = body.match(/playwright-cli\s+open\s+--browser=chrome\s+--headless\s+(https?:\/\/\S+)/i);
236
315
  if (match) {
237
316
  try {
238
317
  const url = new URL(match[1].replace(/[)\]},.\"'`]+$/, ""));
@@ -0,0 +1,104 @@
1
+ import { readFile, rename, unlink, writeFile, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const DIMENSIONS = new Set(["core", "boundary", "flow", "backend"]);
4
+ function fail(ruleId, detail) {
5
+ const msg = JSON.stringify({ ruleId, detail: String(detail ?? "") });
6
+ console.error(`frontend-test manifest blocked: ${msg}`);
7
+ throw new Error(`frontend-test manifest blocked: ${ruleId}${detail ? `: ${detail}` : ""}`);
8
+ }
9
+ /**
10
+ * Validate manifest.draft.json and atomically materialize
11
+ * testcase/frontend/cases/manifest.json via temp+rename, then delete the draft.
12
+ *
13
+ * Writes only a normalized { schemaVersion: 1, cases } payload — never the raw
14
+ * draft object — so generator typos in casePath/evidenceDir/extra fields cannot
15
+ * leak into the authoritative manifest.
16
+ *
17
+ * unknown-ac is enforced only when declaredAcIds is non-empty (sourceBinding
18
+ * present). When empty, ac-id-shape still applies.
19
+ */
20
+ export async function materializeFrontendTestCaseManifest(input) {
21
+ const draft = path.join(input.workspaceRoot, "testcase/frontend/cases/manifest.draft.json");
22
+ const file = path.join(input.workspaceRoot, "testcase/frontend/cases/manifest.json");
23
+ const tmp = `${file}.tmp`;
24
+ const maxCases = input.maxCases ?? 32;
25
+ const declaredAc = new Set(input.declaredAcIds ?? []);
26
+ let manifest;
27
+ try {
28
+ manifest = JSON.parse(await readFile(draft, "utf8"));
29
+ }
30
+ catch {
31
+ fail("draft-missing", `missing ${draft}`);
32
+ }
33
+ if (manifest.schemaVersion !== 1)
34
+ fail("draft-schema", "schemaVersion must be 1");
35
+ if (!Array.isArray(manifest.cases) || manifest.cases.length === 0)
36
+ fail("draft-empty-cases", "cases must be a non-empty array");
37
+ if (manifest.cases.length > maxCases)
38
+ fail("map-capacity-exceeded", `cases=${manifest.cases.length} exceeds maxCasesPerBatch/maxExpandedNodes=${maxCases}; raise frontendTest.maxCasesPerBatch or shrink the suite`);
39
+ const seen = new Set();
40
+ const seenCasePath = new Set();
41
+ const seenEvidenceDir = new Set();
42
+ const acIdRe = /^AC(?:-[A-Z0-9]+)+$/i;
43
+ const cases = [];
44
+ for (const raw of manifest.cases) {
45
+ const c = raw;
46
+ if (!c ||
47
+ typeof c.caseId !== "string" ||
48
+ !/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/.test(c.caseId))
49
+ fail("case-id-shape", `caseId must be FE-*, never AC-FE-*: ${String(c?.caseId)}`);
50
+ if (/^AC-/i.test(c.caseId))
51
+ fail("case-id-is-ac", `caseId must not be an acceptance id: ${c.caseId}`);
52
+ if (seen.has(c.caseId))
53
+ fail("duplicate-case-id", c.caseId);
54
+ seen.add(c.caseId);
55
+ if (typeof c.dimension !== "string" || !DIMENSIONS.has(c.dimension))
56
+ fail("invalid-dimension", String(c.dimension));
57
+ if (!Array.isArray(c.acIds) ||
58
+ c.acIds.length === 0 ||
59
+ c.acIds.some((a) => typeof a !== "string" || !a.trim()))
60
+ fail("ac-mapping", `invalid acIds for ${c.caseId}`);
61
+ const acIds = [];
62
+ for (const ac of c.acIds) {
63
+ if (typeof ac !== "string" || !acIdRe.test(ac))
64
+ fail("ac-id-shape", `acIds entry must be AC-* acceptance id, not caseId: ${ac}`);
65
+ if (declaredAc.size > 0 && !declaredAc.has(ac))
66
+ fail("unknown-ac", `${ac} not in sourceBinding; repair generator input or AC list`);
67
+ acIds.push(ac);
68
+ }
69
+ const casePath = `testcase/frontend/cases/${c.caseId}.md`;
70
+ const evidenceDir = `testcase/frontend/evidence/${c.caseId}/`;
71
+ for (const [k, v] of [
72
+ ["casePath", casePath],
73
+ ["evidenceDir", evidenceDir],
74
+ ]) {
75
+ if (path.isAbsolute(v) || v.includes(".."))
76
+ fail("unsafe-path", `${k}: ${v}`);
77
+ }
78
+ const casePathAbs = path.join(input.workspaceRoot, casePath);
79
+ const body = await readFile(casePathAbs).catch(() => null);
80
+ if (body === null)
81
+ fail("case-file-missing", `missing case file ${casePath} (filename must equal caseId.md)`);
82
+ if (seenCasePath.has(casePath))
83
+ fail("duplicate-case-path", casePath);
84
+ seenCasePath.add(casePath);
85
+ if (seenEvidenceDir.has(evidenceDir))
86
+ fail("duplicate-evidence-dir", evidenceDir);
87
+ seenEvidenceDir.add(evidenceDir);
88
+ cases.push({
89
+ caseId: c.caseId,
90
+ casePath,
91
+ evidenceDir,
92
+ dimension: c.dimension,
93
+ acIds,
94
+ });
95
+ }
96
+ const payload = `${JSON.stringify({ schemaVersion: 1, cases }, null, 2)}\n`;
97
+ await mkdir(path.dirname(tmp), { recursive: true });
98
+ await writeFile(tmp, payload, "utf8");
99
+ await rename(tmp, file);
100
+ await unlink(draft).catch(() => {
101
+ /* draft already absent */
102
+ });
103
+ return { cases, manifestPath: file };
104
+ }
@@ -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()),