@tea-agent/loop-agent 0.33.2 → 0.33.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.33.4] - 2026-08-10
6
+
7
+ ### 重点更新
8
+
9
+ - 修复后端测试场景参数评估器对 fallback 字段的误判问题,解除由此导致的错误执行阻断
10
+
11
+ ### 修复
12
+
13
+ - 修复 backend-test 场景参数评估器在 Markdown 无可解析场景意图时,将 TP-ID 推断的 fallback 字段(如 `TP-CREATE-APPROVER-FORMAT` → `cREATE`/`pattern-invalid`)被大写启发式误判为不匹配的问题;此类条目现统一判定为无法判定且不可修复,不再生成无效修复建议,由此导致的错误执行就绪阻断已解除
14
+ - 修复 fallback 场景参数状态被错误标记为失败的问题,现根据实际情况正确标记为不可用或部分可用,且不弱化任何就绪性门禁
15
+
16
+ ## [0.33.3] - 2026-08-10
17
+
18
+ ### 重点更新
19
+
20
+ - 修复 Windows 环境下 frontend-test 配置 URL 冻结异常的问题,确保跨平台地址识别行为一致
21
+
22
+ ### 改进
23
+
24
+ - 改进 frontend-test 的 Windows URL 回归测试,使其具备更好的跨平台可移植性
25
+
26
+ ### 修复
27
+
28
+ - 修复 Windows 环境下 frontend-test 配置 URL(如 baseUrl、targetUrl、loginUrl)无法正确冻结的问题,冲突候选现安全 fail closed,仅在完全未配置时回退 localhost
29
+
5
30
  ## [0.33.2] - 2026-08-10
6
31
 
7
32
  ### 重点更新
@@ -135,6 +135,7 @@ export function inferScenarioParamIntent(input) {
135
135
  field,
136
136
  bound,
137
137
  example,
138
+ intentSource: "machine-line",
138
139
  };
139
140
  }
140
141
  return {
@@ -142,6 +143,7 @@ export function inferScenarioParamIntent(input) {
142
143
  field,
143
144
  bound,
144
145
  example,
146
+ intentSource: "machine-line",
145
147
  };
146
148
  }
147
149
  const upper = tpId.toUpperCase();
@@ -178,37 +180,39 @@ export function inferScenarioParamIntent(input) {
178
180
  const exampleMatch = /(?:反例|invalid example|example)\s*[=::]\s*[`"]?([^`"\n]+)[`"]?/i.exec(caseBody);
179
181
  const example = exampleMatch?.[1]?.trim();
180
182
  if (/EMPTY|空白|空串|空字符串/.test(upper) || /空字符串|空串|empty string/i.test(caseBody)) {
181
- return { intent: "empty", field, bound, example };
183
+ return { intent: "empty", field, bound, example, intentSource: "tp-fallback" };
182
184
  }
183
185
  if (/MISSING|缺省|缺失|省略/.test(upper) || /缺少字段|缺失字段|missing field/i.test(caseBody)) {
184
- return { intent: "missing", field, bound, example };
186
+ return { intent: "missing", field, bound, example, intentSource: "tp-fallback" };
185
187
  }
186
188
  if (/\bNULL\b|空值/.test(upper) || /\bnull\b/i.test(caseBody)) {
187
- return { intent: "null", field, bound, example };
189
+ return { intent: "null", field, bound, example, intentSource: "tp-fallback" };
188
190
  }
189
191
  if (/MAX[-_]?PLUS[-_]?1|MAX\+1|超长|越界/.test(upper)) {
190
- return { intent: "max+1", field, bound, example };
192
+ return { intent: "max+1", field, bound, example, intentSource: "tp-fallback" };
193
+ }
194
+ if (/\bMAX\b|最大/.test(upper)) {
195
+ return { intent: "max", field, bound, example, intentSource: "tp-fallback" };
191
196
  }
192
- if (/\bMAX\b|最大/.test(upper))
193
- return { intent: "max", field, bound, example };
194
197
  if (/MIN[-_]?1|最小减|min-1/i.test(upper)) {
195
- return { intent: "min-1", field, bound, example };
198
+ return { intent: "min-1", field, bound, example, intentSource: "tp-fallback" };
199
+ }
200
+ if (/\bMIN\b|最小/.test(upper)) {
201
+ return { intent: "min", field, bound, example, intentSource: "tp-fallback" };
196
202
  }
197
- if (/\bMIN\b|最小/.test(upper))
198
- return { intent: "min", field, bound, example };
199
203
  if (/WRONG[-_]?TYPE|类型错误/.test(upper)) {
200
- return { intent: "wrong-type", field, bound, example };
204
+ return { intent: "wrong-type", field, bound, example, intentSource: "tp-fallback" };
201
205
  }
202
206
  if (/ENUM.*INVALID|INVALID.*ENUM|非法枚举/.test(upper)) {
203
- return { intent: "enum-invalid", field, bound, example };
207
+ return { intent: "enum-invalid", field, bound, example, intentSource: "tp-fallback" };
204
208
  }
205
209
  if (/PATTERN|FORMAT|UPPERCASE|大写|非法格式/.test(upper)) {
206
- return { intent: "pattern-invalid", field, bound, example };
210
+ return { intent: "pattern-invalid", field, bound, example, intentSource: "tp-fallback" };
207
211
  }
208
212
  if (/NOMINAL|正常|合法|主路径|SUCCESS/.test(upper)) {
209
- return { intent: "nominal", field, bound, example };
213
+ return { intent: "nominal", field, bound, example, intentSource: "tp-fallback" };
210
214
  }
211
- return { intent: "unknown", field, bound, example };
215
+ return { intent: "unknown", field, bound, example, intentSource: "tp-fallback" };
212
216
  }
213
217
  export function extractPytestParamBlock(source, tpId) {
214
218
  const idPattern = new RegExp(`id\\s*=\\s*["']${tpId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`);
@@ -316,7 +320,7 @@ export function observeParamFeatures(block, field) {
316
320
  }
317
321
  return { kind: "unknown", text: compact.slice(0, 160) };
318
322
  }
319
- function compareIntent(intent, observed, bound) {
323
+ function compareIntent(intent, observed, bound, example) {
320
324
  if (intent === "unknown")
321
325
  return "UNDETERMINED";
322
326
  if (observed.kind === "call" || observed.kind === "name") {
@@ -383,10 +387,9 @@ function compareIntent(intent, observed, bound) {
383
387
  ? "MISMATCH"
384
388
  : "UNDETERMINED";
385
389
  case "pattern-invalid":
386
- if (observed.kind === "string" && observed.hasUppercase)
387
- return "MATCH";
388
- if (observed.kind === "string")
389
- return "MISMATCH";
390
+ if (observed.kind === "string" && example !== undefined) {
391
+ return observed.literal === example ? "MATCH" : "MISMATCH";
392
+ }
390
393
  return "UNDETERMINED";
391
394
  case "wrong-type":
392
395
  return observed.kind === "number" ||
@@ -433,14 +436,9 @@ function repairabilityFor(status, intent, observed, example) {
433
436
  }
434
437
  if (intent === "unknown")
435
438
  return "blocked";
436
- if ((intent === "pattern-invalid" ||
437
- intent === "enum-invalid" ||
438
- intent === "wrong-type") &&
439
+ if ((intent === "enum-invalid" || intent === "wrong-type") &&
439
440
  !example &&
440
441
  !intent.startsWith("custom-literal:")) {
441
- // pattern-invalid with uppercase heuristic is still repairable to a fixed anti-example.
442
- if (intent === "pattern-invalid")
443
- return "repairable";
444
442
  return "blocked";
445
443
  }
446
444
  if (CLOSED_SET.has(intent) || intent.startsWith("custom-literal:")) {
@@ -475,8 +473,8 @@ function suggestedFixFor(intent, field, bound, example) {
475
473
  ? `set ${field}="x"*${Math.max(0, bound - 1)}`
476
474
  : undefined;
477
475
  case "pattern-invalid":
478
- return field
479
- ? `set ${field}=${JSON.stringify(example ?? "INVALID")}`
476
+ return field && example
477
+ ? `set ${field}=${JSON.stringify(example)}`
480
478
  : undefined;
481
479
  case "enum-invalid":
482
480
  case "wrong-type":
@@ -509,11 +507,12 @@ export async function assessBackendScenarioParamConsistency(input) {
509
507
  tpId,
510
508
  caseBody: testCase.body,
511
509
  });
510
+ const fallbackSourced = inferred.intentSource === "tp-fallback";
512
511
  const block = source ? extractPytestParamBlock(source, tpId) : undefined;
513
512
  const observed = observeParamFeatures(block, inferred.field);
514
- const status = !source
513
+ const status = !source || fallbackSourced
515
514
  ? "UNDETERMINED"
516
- : compareIntent(inferred.intent, observed, inferred.bound);
515
+ : compareIntent(inferred.intent, observed, inferred.bound, inferred.example);
517
516
  const repairability = repairabilityFor(status, inferred.intent, observed, inferred.example);
518
517
  entries.push({
519
518
  caseId: testCase.caseId,
@@ -523,7 +522,9 @@ export async function assessBackendScenarioParamConsistency(input) {
523
522
  observed: observed.text,
524
523
  status,
525
524
  repairability,
526
- suggestedFix: suggestedFixFor(inferred.intent, inferred.field, inferred.bound, inferred.example),
525
+ suggestedFix: fallbackSourced
526
+ ? undefined
527
+ : suggestedFixFor(inferred.intent, inferred.field, inferred.bound, inferred.example),
527
528
  scriptPath: testCase.scriptPath,
528
529
  bound: inferred.bound,
529
530
  example: inferred.example,
@@ -691,7 +692,14 @@ export function deterministicRewriteScenarioParam(input) {
691
692
  valueLiteral = `"x" * ${Math.max(0, bound - 1)}`;
692
693
  break;
693
694
  case "pattern-invalid":
694
- valueLiteral = JSON.stringify(entry.example ?? "INVALID");
695
+ if (entry.example === undefined) {
696
+ return {
697
+ ok: false,
698
+ source: input.source,
699
+ detail: "missing example for pattern-invalid",
700
+ };
701
+ }
702
+ valueLiteral = JSON.stringify(entry.example);
695
703
  break;
696
704
  default:
697
705
  if (entry.intent.startsWith("custom-literal:")) {
@@ -25,21 +25,24 @@ function writeMetric(label, value, color = "#172033") {
25
25
  }
26
26
  function renderCaseCard(item) {
27
27
  const colors = statusColor(item.status);
28
- const rerun = typeof item.rerunAttempt === "number" &&
29
- item.rerunAttempt > 0
28
+ const rerun = typeof item.rerunAttempt === "number" && item.rerunAttempt > 0
30
29
  ? `<span class="badge" style="color:#175cd3;background:#eff8ff">重跑 ${item.rerunAttempt}</span>`
31
30
  : "";
32
31
  const error = item.status === "passed"
33
32
  ? ""
34
33
  : `<div class="error-box"><strong>原因分析</strong><p>${escapeHtml(item.errorAnalysis ?? item.blockedReason ?? "用例未能完成执行。")}</p></div>`;
35
- const execution = item.status === "passed"
34
+ const testPoints = item.testPoints ?? [];
35
+ const executionSummary = item.executionSummary ?? "";
36
+ const executionSteps = item.executionSteps ?? [];
37
+ const hasExecution = executionSummary.trim().length > 0 || executionSteps.length > 0;
38
+ const execution = !hasExecution
36
39
  ? ""
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
40
+ : `<div class="execution-box"><strong>执行记录</strong>${executionSummary.trim().length > 0
41
+ ? `<p>${escapeHtml(executionSummary)}</p>`
42
+ : ""}${executionSteps.length > 0
43
+ ? listHtml(executionSteps)
44
+ : ""}</div>`;
45
+ const testPointsHtml = testPoints.length
43
46
  ? `<div><h3>测试点</h3>${listHtml(testPoints)}</div>`
44
47
  : "";
45
48
  return `<details class="case-card" data-status="${item.status}" ${item.status !== "passed" ? "open" : ""}>
@@ -53,8 +56,8 @@ function renderCaseCard(item) {
53
56
  <div><h3>测试步骤</h3>${listHtml(item.caseContent.steps)}</div>
54
57
  <div><h3>预期结果</h3>${listHtml(item.caseContent.expectedResults)}</div>
55
58
  </div>
56
- ${error}
57
59
  ${execution}
60
+ ${error}
58
61
  </div>
59
62
  </details>`;
60
63
  }
@@ -87,18 +90,30 @@ export async function renderFrontendTestHtmlReport(input) {
87
90
  ? (result.acceptanceCoverage.covered.length / result.sourceBinding.requirementIds.length) * 100
88
91
  : 0;
89
92
  const outcomeColors = result.outcome === "passed" ? statusColor("passed") : result.outcome === "failed" ? statusColor("failed") : statusColor("blocked");
90
- const markdownCases = result.cases.map((item) => [
91
- `## ${item.caseId}`,
92
- "",
93
- `- 执行结果:${statusLabel(item.status)}`,
94
- `- 验收标准:${item.acIds.join("、")}`,
95
- "",
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 ?? "用例未能完成执行。"]),
101
- ].join("\n")).join("\n\n");
93
+ const markdownCases = result.cases.map((item) => {
94
+ const testPoints = item.testPoints ?? [];
95
+ const executionSummary = item.executionSummary ?? "";
96
+ const executionSteps = item.executionSteps ?? [];
97
+ return [
98
+ `## ${item.caseId}`,
99
+ "",
100
+ `- 执行结果:${statusLabel(item.status)}`,
101
+ `- 验收标准:${item.acIds.join("、")}`,
102
+ ...(typeof item.rerunAttempt === "number" && item.rerunAttempt > 0
103
+ ? [`- 重跑次数:${item.rerunAttempt}`]
104
+ : []),
105
+ "",
106
+ "### 测试目的", "", item.caseContent.purpose,
107
+ ...(testPoints.length ? ["", "### 测试点", "", listMarkdown(testPoints)] : []),
108
+ "", "### 前置条件", "", listMarkdown(item.caseContent.preconditions),
109
+ "", "### 测试步骤", "", listMarkdown(item.caseContent.steps),
110
+ "", "### 预期结果", "", listMarkdown(item.caseContent.expectedResults),
111
+ ...(executionSummary.trim().length || executionSteps.length
112
+ ? ["", "### 执行记录", "", executionSummary.trim(), ...(executionSteps.length ? ["", listMarkdown(executionSteps)] : [])]
113
+ : []),
114
+ ...(item.status === "passed" ? [] : ["", "### 错误分析", "", item.errorAnalysis ?? item.blockedReason ?? "用例未能完成执行。"]),
115
+ ];
116
+ }).map((lines) => lines.filter((line) => line !== undefined).join("\n")).join("\n\n");
102
117
  const markdown = [
103
118
  "# 前端功能测试报告", "",
104
119
  `- 测试结论:${outcomeLabel}`,
@@ -117,7 +132,7 @@ export async function renderFrontendTestHtmlReport(input) {
117
132
  ? `<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
133
  : "";
119
134
  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%}}
135
+ 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}.execution-box{margin-top:13px;padding:11px 13px;border:1px solid #b9d6f5;border-radius:9px;background:#f5f9ff;color:#1f4e79}.execution-box strong{font-size:12px}.execution-box p{margin:4px 0 0;white-space:pre-wrap;font-size:13px}.execution-box ol{margin:6px 0 0;padding-left:20px;color:#28456b;font-size:13px}.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
136
 
122
137
  .filter-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:12px 0 16px}
123
138
  .filter-label{font-size:13px;color:#475467}
@@ -37,6 +37,7 @@ export const frontendTestResultContractSchema = z.object({
37
37
  rerunAttempt: z.number().int().nonnegative().optional(),
38
38
  testPoints: z.array(z.string()).optional(),
39
39
  executionSummary: z.string().optional(),
40
+ executionSteps: z.array(z.string()).optional(),
40
41
  caseContent: z.object({
41
42
  purpose: z.string(),
42
43
  preconditions: z.array(z.string()),
@@ -233,18 +234,56 @@ function markdownList(value) {
233
234
  const items = value.split(/\r?\n/).map((line) => line.replace(/^\s*(?:\d+[.)]|[-*+])\s+/, "").trim()).filter(Boolean);
234
235
  return items;
235
236
  }
237
+ /**
238
+ * Read planned case facts from the case Markdown. Recognizes both English and
239
+ * Chinese headings, including `测试点` (test points) and `测试步骤` (planned
240
+ * test steps), alongside the legacy `操作步骤`/`Steps` headings.
241
+ */
236
242
  async function readFrontendCaseContent(workspaceRoot, casePath) {
237
243
  if (!safeRelativePathSchema.safeParse(casePath).success || !casePath.startsWith("testcase/frontend/cases/")) {
238
244
  throw new Error(`unsafe frontend case path: ${casePath}`);
239
245
  }
240
246
  const body = await readFile(path.resolve(workspaceRoot, casePath), "utf8");
241
247
  return {
242
- purpose: markdownSection(body, ["Test Purpose", "测试目的", "测试场景"]) || "未提供测试目的",
243
- preconditions: markdownList(markdownSection(body, ["Preconditions", "前置条件"])),
244
- steps: markdownList(markdownSection(body, ["Steps", "操作步骤"])),
245
- expectedResults: markdownList(markdownSection(body, ["Expected Results", "预期结果"])),
248
+ caseContent: {
249
+ purpose: markdownSection(body, ["Test Purpose", "测试目的", "测试场景"]) || "未提供测试目的",
250
+ preconditions: markdownList(markdownSection(body, ["Preconditions", "前置条件"])),
251
+ steps: markdownList(markdownSection(body, ["Test Steps", "测试步骤", "Steps", "操作步骤"])),
252
+ expectedResults: markdownList(markdownSection(body, ["Expected Results", "预期结果"])),
253
+ },
254
+ testPoints: markdownList(markdownSection(body, ["Test Points", "测试点"])),
246
255
  };
247
256
  }
257
+ /**
258
+ * Parse bounded fixed sections from execution.md. Only a small allowlist of
259
+ * headings is recognized and each section is length-capped so model prose
260
+ * cannot flood the report. Missing sections return empty values (advisory only).
261
+ */
262
+ const EXECUTION_SUMMARY_MAX_LENGTH = 1200;
263
+ const EXECUTION_STEPS_MAX_ITEMS = 40;
264
+ function parseExecutionMarkdown(body) {
265
+ const summarySection = markdownSection(body, [
266
+ "Execution Summary",
267
+ "Actual Execution Summary",
268
+ "执行摘要",
269
+ "实际执行摘要",
270
+ "执行总结",
271
+ ]);
272
+ const rawSummary = summarySection.replace(/\r\n/g, "\n").trim();
273
+ const summary = rawSummary.length > EXECUTION_SUMMARY_MAX_LENGTH
274
+ ? `${rawSummary.slice(0, EXECUTION_SUMMARY_MAX_LENGTH - 1)}…`
275
+ : rawSummary;
276
+ const stepsSection = markdownSection(body, [
277
+ "Actual Steps",
278
+ "Actual Execution Steps",
279
+ "Execution Steps",
280
+ "实际执行步骤",
281
+ "实际步骤",
282
+ "执行步骤",
283
+ ]);
284
+ const steps = markdownList(stepsSection).slice(0, EXECUTION_STEPS_MAX_ITEMS);
285
+ return { summary, steps };
286
+ }
248
287
  function assertInside(root, candidate, label) {
249
288
  const relative = path.relative(root, candidate);
250
289
  if (relative.startsWith("..") || path.isAbsolute(relative)) {
@@ -371,7 +410,23 @@ export async function materializeFrontendTestResult(input) {
371
410
  });
372
411
  }
373
412
  }
374
- const caseContent = await readFrontendCaseContent(input.workspaceRoot, item.casePath);
413
+ const { caseContent, testPoints } = await readFrontendCaseContent(input.workspaceRoot, item.casePath);
414
+ // Parse bounded fixed sections from execution.md. Missing optional
415
+ // summary/steps are advisory only and never downgrade a passed status.
416
+ let executionSummary = "";
417
+ let executionSteps = [];
418
+ try {
419
+ const executionBody = await readFile(path.join(evidenceRoot, "execution.md"), "utf8");
420
+ const parsed = parseExecutionMarkdown(executionBody);
421
+ executionSummary = parsed.summary;
422
+ executionSteps = parsed.steps;
423
+ if (!executionSummary && executionSteps.length === 0) {
424
+ advisoryFindings.push({ ruleId: "execution-summary-unavailable", caseId: item.caseId, detail: "execution.md has no bounded execution summary/steps; reporting remains status-driven" });
425
+ }
426
+ }
427
+ catch {
428
+ advisoryFindings.push({ ruleId: "execution-summary-unavailable", caseId: item.caseId, detail: "execution.md summary could not be parsed; reporting remains status-driven" });
429
+ }
375
430
  const explicitAnalysis = typeof resultRaw.errorAnalysis === "string" && resultRaw.errorAnalysis.trim()
376
431
  ? resultRaw.errorAnalysis.trim()
377
432
  : typeof resultRaw.errorSummary === "string" && resultRaw.errorSummary.trim()
@@ -380,7 +435,23 @@ export async function materializeFrontendTestResult(input) {
380
435
  const errorAnalysis = status === "passed"
381
436
  ? undefined
382
437
  : explicitAnalysis ?? (status === "blocked" ? `用例因 ${blockedReason ?? "未知原因"} 未能完成执行。` : "用例执行失败,但执行结果未提供详细错误分析。");
383
- cases.push({ caseId: item.caseId, acIds: item.acIds, status, evidence, caseContent, ...(blockedReason ? { blockedReason } : {}), ...(errorAnalysis ? { errorAnalysis } : {}) });
438
+ const rerunAttemptRaw = resultRaw.rerunAttempt;
439
+ const rerunAttempt = typeof rerunAttemptRaw === "number" && Number.isFinite(rerunAttemptRaw) && rerunAttemptRaw >= 0
440
+ ? Math.trunc(rerunAttemptRaw)
441
+ : undefined;
442
+ cases.push({
443
+ caseId: item.caseId,
444
+ acIds: item.acIds,
445
+ status,
446
+ evidence,
447
+ caseContent,
448
+ ...(blockedReason ? { blockedReason } : {}),
449
+ ...(errorAnalysis ? { errorAnalysis } : {}),
450
+ ...(rerunAttempt !== undefined ? { rerunAttempt } : {}),
451
+ ...(testPoints.length > 0 ? { testPoints } : {}),
452
+ ...(executionSummary ? { executionSummary } : {}),
453
+ ...(executionSteps.length > 0 ? { executionSteps } : {}),
454
+ });
384
455
  }
385
456
  const missing = [...requiredIds].filter((id) => !covered.has(id));
386
457
  const totals = {
@@ -3939,34 +3939,129 @@ async function buildBackendTestHybridDag(sources) {
3939
3939
  assertValidDagSpec(spec);
3940
3940
  return spec;
3941
3941
  }
3942
+ // Explicit allowlist of controller-owned reference filenames that may declare
3943
+ // a non-production browser URL. Hash-bound task sources are the only authority;
3944
+ // model-authored RAG/case/evidence files are never consulted.
3945
+ const FRONTEND_BASE_URL_DOCUMENT_ALLOWLIST = [
3946
+ /(?:^|[\\/])config\.md$/i,
3947
+ /(?:^|[\\/])environment\.md$/i,
3948
+ /(?:^|[\\/])env\.md$/i,
3949
+ /(?:^|[\\/])urls?\.md$/i,
3950
+ /(?:^|[\\/])references\.md$/i,
3951
+ /(?:^|[\\/])task-reference\.md$/i,
3952
+ /(?:^|[\\/])task-source\.md$/i,
3953
+ ];
3954
+ // Explicit allowlist of URL keys. The semantic name drives a deterministic
3955
+ // priority so two conflicting same-priority URLs fail closed instead of being
3956
+ // silently picked.
3957
+ const FRONTEND_BASE_URL_KEY_ALLOWLIST = [
3958
+ // The primary frontend browser entry point is the strongest signal.
3959
+ { name: "baseurl", priority: 0 },
3960
+ { name: "base url", priority: 0 },
3961
+ { name: "targeturl", priority: 0 },
3962
+ { name: "target url", priority: 0 },
3963
+ { name: "frontendbaseurl", priority: 1 },
3964
+ { name: "frontend base url", priority: 1 },
3965
+ { name: "frontendurl", priority: 1 },
3966
+ { name: "frontend url", priority: 1 },
3967
+ { name: "loginurl", priority: 2 },
3968
+ { name: "login url", priority: 2 },
3969
+ ];
3970
+ const DEFAULT_FRONTEND_BASE_URL = "http://localhost:5173/";
3971
+ const DEFAULT_FRONTEND_BASE_URL_SOURCE = "default-localhost-5173";
3972
+ function isAllowedFrontendBaseUrlDocument(documentPath) {
3973
+ return FRONTEND_BASE_URL_DOCUMENT_ALLOWLIST.some((pattern) => pattern.test(documentPath));
3974
+ }
3975
+ function normalizeFrontendBaseUrlKey(raw) {
3976
+ // Normalize separators: kebab/snake/space variants collapse to a comparable
3977
+ // token. Lowercase so casing differences do not bypass the allowlist.
3978
+ return raw
3979
+ .toLowerCase()
3980
+ .replace(/[_\-]+/g, " ")
3981
+ .replace(/\s+/g, " ")
3982
+ .trim();
3983
+ }
3942
3984
  /**
3943
3985
  * Resolve the browser origin only from controller-owned task source bytes.
3944
3986
  * Model-authored RAG/case/evidence files are intentionally excluded.
3987
+ *
3988
+ * Safety contract:
3989
+ * - Only hash-bound controller reference documents on the filename allowlist
3990
+ * may declare a URL.
3991
+ * - Only the explicit key allowlist (baseUrl / targetUrl / loginUrl + common
3992
+ * case/separator variants) is recognized.
3993
+ * - Candidates are ranked deterministically by key priority then by document
3994
+ * order; equal-priority conflicting URLs fail closed.
3995
+ * - The resolved URL must be non-production http(s) without credentials, query,
3996
+ * or fragment. Only when zero candidates exist does the localhost default
3997
+ * apply.
3945
3998
  */
3946
3999
  export function resolveControllerFrontendBaseUrl(sources) {
3947
- const candidates = (sources.referenceDocuments ?? [])
3948
- .filter((document) => /(?:^|[\\/])config\.md$/i.test(document.path))
3949
- .map((document) => ({
3950
- markdown: document.markdown,
3951
- source: toDagSourcePath(sources, document.path),
3952
- }));
3953
- let rawBaseUrl = "http://localhost:5173";
3954
- let baseUrlSource = "default-localhost-5173";
3955
- for (const candidate of candidates) {
3956
- const match = candidate.markdown.match(/(?:frontend[_-]?base[_-]?url|base[_-]?url|base[- ]url)\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i);
3957
- if (!match?.[1]) {
3958
- if (/(?:frontend[_-]?base[_-]?url|base[_-]?url|base[- ]url)\s*[:=]/i.test(candidate.markdown)) {
3959
- throw new Error(`frontend-test controller baseUrl is invalid (${candidate.source})`);
4000
+ const allowedKeyByNormalized = new Map(FRONTEND_BASE_URL_KEY_ALLOWLIST.map((variant) => [
4001
+ variant.name,
4002
+ variant.priority,
4003
+ ]));
4004
+ const candidates = [];
4005
+ const documents = sources.referenceDocuments ?? [];
4006
+ documents.forEach((document, documentIndex) => {
4007
+ if (!isAllowedFrontendBaseUrlDocument(document.path))
4008
+ return;
4009
+ const source = toDagSourcePath(sources, document.path);
4010
+ // Only accept `key: value` / `key = value` lines. A colon or equals
4011
+ // sign after a known key bounds the URL; free-form prose is ignored.
4012
+ const keyPattern = /^[ ]*(?:#>*)?[ ]*([A-Za-z][A-Za-z0-9 _\-]*?)[ ]*[:=][ ]*["'`]?((?:https?:)?\/\/[\S]+?)(?:["'`])?[ ]*(?:#.*)?$/gm;
4013
+ let match;
4014
+ while ((match = keyPattern.exec(document.markdown)) !== null) {
4015
+ const rawKey = match[1].trim();
4016
+ const normalizedKey = normalizeFrontendBaseUrlKey(rawKey);
4017
+ const priority = allowedKeyByNormalized.get(normalizedKey);
4018
+ if (priority === undefined)
4019
+ continue;
4020
+ let rawUrl = match[2].replace(/[)\]},.;]+$/, "");
4021
+ if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(rawUrl)) {
4022
+ rawUrl = `https://${rawUrl.replace(/^\/\/+/, "")}`;
3960
4023
  }
3961
- continue;
4024
+ candidates.push({
4025
+ url: rawUrl,
4026
+ source,
4027
+ keyName: normalizedKey,
4028
+ priority,
4029
+ documentIndex,
4030
+ });
3962
4031
  }
3963
- rawBaseUrl = match[1].replace(/[)\]},.;]+$/, "");
3964
- baseUrlSource = candidate.source;
3965
- break;
4032
+ });
4033
+ if (candidates.length === 0) {
4034
+ return {
4035
+ baseUrl: DEFAULT_FRONTEND_BASE_URL,
4036
+ baseUrlSource: DEFAULT_FRONTEND_BASE_URL_SOURCE,
4037
+ };
3966
4038
  }
4039
+ // Deterministic ranking: lowest priority number first, then earliest
4040
+ // document, then earliest occurrence in that document.
4041
+ candidates.sort((left, right) => {
4042
+ if (left.priority !== right.priority) {
4043
+ return left.priority - right.priority;
4044
+ }
4045
+ if (left.documentIndex !== right.documentIndex) {
4046
+ return left.documentIndex - right.documentIndex;
4047
+ }
4048
+ return 0;
4049
+ });
4050
+ const bestPriority = candidates[0].priority;
4051
+ const bestPriorityCandidates = candidates.filter((candidate) => candidate.priority === bestPriority);
4052
+ // Fail closed: two distinct URLs at the same priority level are ambiguous.
4053
+ const distinctUrls = new Set(bestPriorityCandidates.map((candidate) => candidate.url));
4054
+ if (distinctUrls.size > 1) {
4055
+ const conflictingSources = bestPriorityCandidates
4056
+ .map((candidate) => `${candidate.source}:${candidate.keyName}`)
4057
+ .join(", ");
4058
+ throw new Error(`frontend-test controller baseUrl is ambiguous (${conflictingSources})`);
4059
+ }
4060
+ const selected = bestPriorityCandidates[0];
4061
+ const baseUrlSource = selected.source;
3967
4062
  let parsed;
3968
4063
  try {
3969
- parsed = new URL(rawBaseUrl);
4064
+ parsed = new URL(selected.url);
3970
4065
  }
3971
4066
  catch {
3972
4067
  throw new Error(`frontend-test controller baseUrl is invalid (${baseUrlSource})`);
@@ -4399,10 +4494,28 @@ function buildFrontendTestHybridDag(sources) {
4399
4494
  },
4400
4495
  },
4401
4496
  });
4402
- if (maxRerunAttempts > 0) {
4497
+ let rerunDependency = "execute-frontend-cases-map";
4498
+ for (let round = 1; round <= maxRerunAttempts; round += 1) {
4499
+ const selectorId = round === 1
4500
+ ? "select-frontend-rerun-candidates-shell"
4501
+ : `select-frontend-rerun-candidates-round${round}-shell`;
4502
+ const mapId = round === 1
4503
+ ? "rerun-frontend-cases-map"
4504
+ : `rerun-frontend-cases-round${round}-map`;
4505
+ const candidateArtifact = round === 1
4506
+ ? "rerun-candidates.json"
4507
+ : `rerun-candidates-round${round}.json`;
4508
+ const selectCommand = [
4509
+ "const fs=require('fs'),path=require('path');",
4510
+ "const manifestPath='testcase/frontend/cases/manifest.json';",
4511
+ "if(!fs.existsSync(manifestPath)){process.stdout.write(JSON.stringify({cases:[]}));process.exit(0);}",
4512
+ "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));const cases=[];",
4513
+ "for(const c of (manifest.cases||[])){const evidenceDir=(c.evidenceDir||('testcase/frontend/evidence/'+c.caseId+'/')).replace(/\/+$/,'')+'/';const resultPath=path.join(evidenceDir,'case-result.json');const execPath=path.join(evidenceDir,'execution.md');let reason=null;let attempt=0;let missing=false;if(!fs.existsSync(resultPath)){missing=true;reason='missing-result-files';}else{try{const r=JSON.parse(fs.readFileSync(resultPath,'utf8'));attempt=Number(r.rerunAttempt||0)||0;if(r.status==='blocked')reason='blocked';if(!r.status){missing=true;reason='missing-result-files';}}catch(_){missing=true;reason='missing-result-files';}}if(!fs.existsSync(execPath)&&reason!=='blocked'){missing=true;reason=reason||'missing-result-files';}const should=(reason==='blocked'||missing)&&attempt<" + String(round) + ";if(should){cases.push({caseId:c.caseId,casePath:c.casePath||('testcase/frontend/cases/'+c.caseId+'.md'),evidenceDir,dimension:c.dimension||'core',acIds:c.acIds||[],rerunAttempt:attempt+1,reason:reason||'blocked'});}}",
4514
+ `fs.mkdirSync('testcase/frontend/evidence',{recursive:true});fs.writeFileSync('testcase/frontend/evidence/${candidateArtifact}',JSON.stringify({schemaVersion:1,cases},null,2)+'\n');process.stdout.write(JSON.stringify({cases}));`,
4515
+ ].join("");
4403
4516
  tasks.push({
4404
- id: "select-frontend-rerun-candidates-shell",
4405
- depends_on: ["execute-frontend-cases-map"],
4517
+ id: selectorId,
4518
+ depends_on: [rerunDependency],
4406
4519
  role: "verifier",
4407
4520
  executor: "shell",
4408
4521
  complexity: "LOW",
@@ -4410,42 +4523,33 @@ function buildFrontendTestHybridDag(sources) {
4410
4523
  writeSet: ["testcase/frontend/evidence/**"],
4411
4524
  allowedPaths: [...casesWriteSet, "testcase/frontend/evidence/**"],
4412
4525
  forbiddenPaths: forbidden,
4413
- outputContract: "Stdout JSON {cases} for blocked or missing-result-file cases with rerunAttempt < maxRerunAttempts.",
4414
- subtask_prompt: "Select frontend-test cases eligible for bounded rerun.",
4526
+ outputContract: `Stdout JSON {cases} for blocked or missing-result-file cases eligible for rerun round ${round}/${maxRerunAttempts}.`,
4527
+ subtask_prompt: "Select frontend-test cases eligible for this bounded rerun round.",
4415
4528
  shell: {
4416
- commands: [
4417
- [
4418
- "node -e",
4419
- JSON.stringify("const fs=require('fs'),path=require('path');const manifestPath='testcase/frontend/cases/manifest.json';if(!fs.existsSync(manifestPath)){process.stdout.write(JSON.stringify({cases:[]}));process.exit(0);}const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));const cases=[];for(const c of (manifest.cases||[])){const evidenceDir=(c.evidenceDir||('testcase/frontend/evidence/'+c.caseId+'/')).replace(/\\/+$/,'')+'/';const resultPath=path.join(evidenceDir,'case-result.json');const execPath=path.join(evidenceDir,'execution.md');let reason=null;let attempt=0;let missing=false;if(!fs.existsSync(resultPath)){missing=true;reason='missing-result-files';}else{try{const r=JSON.parse(fs.readFileSync(resultPath,'utf8'));attempt=Number(r.rerunAttempt||0)||0;if(r.status==='blocked')reason='blocked';if(!r.status){missing=true;reason='missing-result-files';}}catch(e){missing=true;reason='missing-result-files';}}if(!fs.existsSync(execPath)&&reason!=='blocked'){missing=true;reason=reason||'missing-result-files';}const should=(reason==='blocked'||missing)&&attempt<" +
4420
- maxRerunAttempts +
4421
- ";if(should){cases.push({caseId:c.caseId,casePath:c.casePath||('testcase/frontend/cases/'+c.caseId+'.md'),evidenceDir,dimension:c.dimension||'core',acIds:c.acIds||[],rerunAttempt:attempt+1,reason:reason||'blocked'});}}fs.mkdirSync('testcase/frontend/evidence',{recursive:true});fs.writeFileSync('testcase/frontend/evidence/rerun-candidates.json',JSON.stringify({schemaVersion:1,cases},null,2)+'\\n');process.stdout.write(JSON.stringify({cases}));"),
4422
- ].join(" "),
4423
- ],
4529
+ commands: [["node -e", JSON.stringify(selectCommand)].join(" ")],
4424
4530
  cwd: ".",
4425
4531
  timeoutMs: 120_000,
4426
4532
  },
4427
4533
  }, {
4428
- id: "rerun-frontend-cases-map",
4429
- depends_on: ["select-frontend-rerun-candidates-shell"],
4534
+ id: mapId,
4535
+ depends_on: [selectorId],
4430
4536
  role: "verifier",
4431
4537
  executor: "static",
4432
4538
  complexity: "LOW",
4433
4539
  writePolicy: "none",
4434
4540
  allowedPaths: [],
4435
4541
  forbiddenPaths: forbidden,
4436
- outputContract: "Serial rerun of blocked/missing-result frontend cases.",
4542
+ outputContract: `Serial rerun round ${round}/${maxRerunAttempts} of blocked or missing-result frontend cases.`,
4437
4543
  subtask_prompt: "Expand rerun candidates into serial browser case children.",
4438
- static: {
4439
- resultMarkdown: "Frontend case rerun map expansion barrier.",
4440
- },
4544
+ static: { resultMarkdown: `Frontend case rerun round ${round} map expansion barrier.` },
4441
4545
  dynamicExpansion: {
4442
4546
  type: "map_agent",
4443
- workflowNodeId: "rerun-frontend-cases-map",
4444
- itemsFrom: "$.nodes['select-frontend-rerun-candidates-shell'].output.cases",
4547
+ workflowNodeId: mapId,
4548
+ itemsFrom: `$.nodes['${selectorId}'].output.cases`,
4445
4549
  itemName: "case",
4446
4550
  maxItems: config.maxCasesPerBatch,
4447
4551
  maxExpandedNodes: config.maxCasesPerBatch,
4448
- childIdPrefix: "rerun-frontend-case",
4552
+ childIdPrefix: `rerun-frontend-case-r${round}`,
4449
4553
  workspaceTemplate: "{{case.evidenceDir}}",
4450
4554
  tolerateChildFailures: true,
4451
4555
  tokenBudget: {
@@ -4457,10 +4561,7 @@ function buildFrontendTestHybridDag(sources) {
4457
4561
  role: "implementer",
4458
4562
  skills: ["playwright-cli"],
4459
4563
  toolProfile: "write",
4460
- commandPolicy: {
4461
- mode: "capability-allowlist",
4462
- capabilities: ["playwright-cli"],
4463
- },
4564
+ commandPolicy: { mode: "capability-allowlist", capabilities: ["playwright-cli"] },
4464
4565
  complexity: "MED",
4465
4566
  writePolicy: "exclusive",
4466
4567
  writeGuardPolicy: "tools-only",
@@ -4472,23 +4573,22 @@ function buildFrontendTestHybridDag(sources) {
4472
4573
  ],
4473
4574
  forbiddenPaths: forbidden,
4474
4575
  writeSet: [`${evidenceRoot}/{{case.caseId}}/**`],
4475
- outputContract: "Compact JSON <=1200 characters with case status, evidence paths, error summary, tokens, and rerunAttempt.",
4576
+ outputContract: "Compact JSON <=1200 characters with final case status, evidence paths, error summary, tokens, and rerunAttempt.",
4476
4577
  subtaskPromptTemplate: [
4477
- "RERUN attempt {{case.rerunAttempt}} for {{case.caseId}} (reason={{case.reason}}). Rewrite authoritative evidence; set rerunAttempt={{case.rerunAttempt}}.",
4578
+ "RERUN attempt {{case.rerunAttempt}} for {{case.caseId}} (reason={{case.reason}}). Rewrite the authoritative execution.md and case-result.json; its final status replaces the earlier case result.",
4478
4579
  "Primary job: EXECUTE case {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session). Use structured playwright_cli only; headless open.",
4479
4580
  `Start via playwright_cli command=open with args [--browser=chrome, ${controllerFrontend.baseUrl}]. Passed requires open → find → cleanup receipts.`,
4480
- "Always write {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json with caseId, status, evidencePaths, rerunAttempt={{case.rerunAttempt}}.",
4581
+ "Always write {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json with caseId, status, evidencePaths, rerunAttempt={{case.rerunAttempt}}. Write fixed sections `### 执行摘要` and `### 实际执行步骤` to execution.md when available.",
4481
4582
  ].join("\n\n"),
4482
4583
  },
4483
4584
  },
4484
4585
  });
4586
+ rerunDependency = mapId;
4485
4587
  }
4486
4588
  tasks.push({
4487
4589
  id: "finalize-frontend-test-result-shell",
4488
4590
  depends_on: [
4489
- maxRerunAttempts > 0
4490
- ? "rerun-frontend-cases-map"
4491
- : "execute-frontend-cases-map",
4591
+ rerunDependency,
4492
4592
  ],
4493
4593
  role: "verifier",
4494
4594
  executor: "shell",
@@ -52,7 +52,7 @@ Each case must require the executor to persist, even when blocked:
52
52
  - `testcase/frontend/evidence/<case-id>/execution.md`
53
53
  - `testcase/frontend/evidence/<case-id>/case-result.json`
54
54
 
55
- `case-result.json` must be valid JSON containing the matching `caseId`, `status` (`passed`, `failed`, or `blocked`), and an `evidencePaths` array. A blocked result must include a non-empty `blockedReason`, such as `isolated-test-environment-unavailable` or `token-budget-exhausted`, and must never claim or imply a pass. `execution.md` records attempted or blocked steps, base-URL safety decision, fixture/reset and request-observation availability, timestamps, and the evidence-file list. Available evidence files are required only when actually produced and must stay under the same case evidence directory.
55
+ `case-result.json` must be valid JSON containing the matching `caseId`, final `status` (`passed`, `failed`, or `blocked`), and an `evidencePaths` array. A rerun must overwrite this same file with its final status and integer `rerunAttempt`; the final report consumes that latest file. A blocked result must include a non-empty `blockedReason`, such as `isolated-test-environment-unavailable` or `token-budget-exhausted`, and must never claim or imply a pass. `execution.md` records attempted or blocked steps, base-URL safety decision, fixture/reset and request-observation availability, timestamps, and the evidence-file list. When available, use fixed headings `### 执行摘要` and `### 实际执行步骤` so the bounded deterministic report extractor can show actual execution facts. Available evidence files are required only when actually produced and must stay under the same case evidence directory.
56
56
 
57
57
 
58
58
  ## Standard scenarios (controller)
@@ -11,18 +11,19 @@
11
11
  "globalConstraints": [
12
12
  "Do not generate pytest or Playwright source code.",
13
13
  "Only use declared isolated test environments; production URLs and real credentials are blocked.",
14
- "Every generated browser start command uses playwright-cli open --browser=chrome followed by the concrete controller-resolved URL (from task source config.md when present, else http://localhost:5173); executable case lines never retain an angle-bracket URL/ref placeholder; subsequent commands stay in that default session and must not use unverified named-session flags.",
14
+ "Every generated browser start command uses playwright-cli open --browser=chrome followed by the concrete controller-resolved URL (resolved from an explicit allowlist of controller-owned, hash-bound task source/reference documents and URL keys: baseUrl, targetUrl, loginUrl and common case/separator variants; http://localhost:5173 is used only when no allowed candidate exists, and conflicting same-priority candidates fail closed); executable case lines never retain an angle-bracket URL/ref placeholder; subsequent commands stay in that default session and must not use unverified named-session flags.",
15
15
  "Case children execute serially. Persist each case result, logs and browser evidence before the next child starts.",
16
16
  "A token threshold is a post-case stop check, not a model hard token cap; unstarted cases must be recorded as blocked: token-budget-exhausted.",
17
17
  "Default pipeline acceptance is the final frontend-test-result-v1 plus testcase/frontend/reports/frontend-test-report.html",
18
18
  "Default frontendTest.reviewMode=off uses mechanical checklist-shell before materialize; set reviewMode=blocking for legacy dual LLM review gate.",
19
19
  "playwright-cli-only: generators and executors may call only skill-declared playwright-cli commands; bare playwright / npx playwright / @playwright/test / Playwright source are forbidden with no native Playwright fallback.",
20
- "Browser-tool preflight (preflight-frontend-browser-tool-shell) must reject CODE_AGENT_PI_BACKEND=cli-only, verify the Pi SDK structured custom-tool surface, freeze baseUrl from hash-bound task source config.md (or controller default localhost), and confirm the verified playwright-cli launcher + --help contract before any frontend-test Pi node; missing capability/CLI fails with zero Pi calls.",
20
+ "Browser-tool preflight (preflight-frontend-browser-tool-shell) must reject CODE_AGENT_PI_BACKEND=cli-only, verify the Pi SDK structured custom-tool surface, freeze baseUrl from allowlisted hash-bound task references and explicit baseUrl/targetUrl/loginUrl keys (or controller default localhost only when no candidate exists), and confirm the verified playwright-cli launcher + --help contract before any frontend-test Pi node; missing capability/CLI fails with zero Pi calls.",
21
21
  "Case executors use structured playwright_cli custom tool under commandPolicy capability-allowlist; playwright-cli stays capability-gated while ordinary writers have bash.",
22
22
  "File outputs use canonical --filename: playwright-cli screenshot --filename final.png (a real target/ref may precede it), playwright-cli pdf --filename final.pdf, and playwright-cli snapshot --filename snapshot.txt only when a snapshot file is needed; a snapshot without filename is response-only. Never use --path, --output, --file, or an output path as a positional target.",
23
23
  "Passed cases require same-child ordered controller receipts: successful open → successful find → successful post-execution cleanup. Pre-start cleanup, snapshot/goto/screenshot/request/console, and ordinary interactions cannot establish passed authority; model prose cannot fake green.",
24
24
  "Environment preflight must curl-probe the frozen non-production baseUrl before generate; unreachable or curl-unavailable ends preflight as blocked (frontend-base-url-unreachable|curl-unavailable) so generate/map do not run.",
25
- "U/D cases must prove current-user data ownership or create cleanable current-user data or authorized Mock; otherwise blocked (current-user-data-unavailable|data-ownership-unverifiable|safe-test-data-setup-unavailable) without cross-user mutation."
25
+ "U/D cases must prove current-user data ownership or create cleanable current-user data or authorized Mock; otherwise blocked (current-user-data-unavailable|data-ownership-unverifiable|safe-test-data-setup-unavailable) without cross-user mutation.",
26
+ "Rerun topology is bounded by frontendTest.maxRerunAttempts (0..4, default 2): the runtime hybrid emits one select+map pair per attempt round, each round only candidates blocked or missing-result cases and rewrites the authoritative case-result.json/execution.md, so the final round's evidence controls the report."
26
27
  ],
27
28
  "tasks": [
28
29
  {
@@ -37,8 +38,8 @@
37
38
  ".harness/**",
38
39
  "artifacts/**"
39
40
  ],
40
- "outputContract": "Deterministic SDK-only browser-tool preflight before any frontend-test Pi node; freeze a controller-owned origin and fail closed with browser-command-capability-unavailable | playwright-cli-unavailable | playwright-cli-contract-incompatible.",
41
- "subtask_prompt": "Reject cli-only rollback, verify the Pi SDK structured custom-tool capability, freeze baseUrl from controller-owned task source/default, and verify the controller-resolved playwright-cli launcher plus --help lists open/close/find/snapshot/click. Do not install packages. Do not start a browser session.",
41
+ "outputContract": "Deterministic SDK-only browser-tool preflight before any frontend-test Pi node; freeze a controller-owned origin (baseUrl/targetUrl/loginUrl allowlist with deterministic priority and fail-closed ambiguity) and fail closed with browser-command-capability-unavailable | playwright-cli-unavailable | playwright-cli-contract-incompatible.",
42
+ "subtask_prompt": "Reject cli-only rollback, verify the Pi SDK structured custom-tool capability, freeze baseUrl from controller-owned task source/reference via the explicit document+key allowlist (baseUrl/targetUrl/loginUrl variants; default localhost only when no candidate; fail closed on ambiguity), and verify the controller-resolved playwright-cli launcher plus --help lists open/close/find/snapshot/click. Do not install packages. Do not start a browser session.",
42
43
  "shell": {
43
44
  "commands": [],
44
45
  "frontendBrowserToolPreflight": {},
@@ -120,7 +121,7 @@
120
121
  "artifacts/**"
121
122
  ],
122
123
  "outputContract": "Fail-closed environment preflight: absolute non-production baseUrl + curl HTTP reachability; writes environmentProbe facts; unreachable => blockedReason frontend-base-url-unreachable (node ERROR so generate/map do not run).",
123
- "subtask_prompt": "Parse frozen baseUrl from context.md (config.md preferred, else http://localhost:5173). Reject production / non-http(s). Probe with curl (HEAD then GET fallback; connect/max-time; no auth/cookie). 2xx/3xx => reachable and continue. 4xx/5xx/DNS/timeout/connection refused/TLS => blockedReason frontend-base-url-unreachable. Missing curl => blockedReason curl-unavailable. Do not start the app. Runtime hybrid generator embeds the authoritative probe script.",
124
+ "subtask_prompt": "Use the controller-frozen baseUrl; the runtime resolver recognizes allowlisted hash-bound reference documents and baseUrl/targetUrl/loginUrl variants, falling back only when no candidate exists. Reject production / non-http(s). Probe with curl (HEAD then GET fallback; connect/max-time; no auth/cookie). 2xx/3xx => reachable and continue. 4xx/5xx/DNS/timeout/connection refused/TLS => blockedReason frontend-base-url-unreachable. Missing curl => blockedReason curl-unavailable. Do not start the app. Runtime hybrid generator embeds the authoritative probe script.",
124
125
  "shell": {
125
126
  "commands": [
126
127
  "node -e \"console.log('template placeholder: runtime hybrid DAG embeds curl preflight; do not use this static command as source of truth')\""
@@ -230,7 +231,7 @@
230
231
  ]
231
232
  },
232
233
  "complexity": "MED",
233
- "subtaskPromptTemplate": "Primary job: EXECUTE {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session). Use the structured playwright_cli tool for every browser action. Do not request or search for bash. Translate each playwright-cli line in the case Markdown into one playwright_cli tool call. The controller-owned browser capability freezes baseUrl from hash-bound task source config.md or the localhost default; context/case prose may reference but cannot establish or override that origin. Start only with playwright_cli command=open and controller-injected concrete frozen URL args (default session; no -s=). Dynamic refs: literal eX/eY are documentation placeholders, not tool args. Immediately before every structured playwright_cli call that references an element, parse the actual eNN from the immediately preceding latest snapshot and pass only that actual eNN. Never send literal eX/eY, and never reuse a stale ref after a new snapshot. File outputs are canonical: screenshot uses [--filename, final.png] (or [e5, --filename, final.png] only for a real target), pdf uses [--filename, final.pdf], and snapshot writes a file only with [--filename, snapshot.txt]; snapshot without filename is response-only. Never use --path, --output, --file, or an output path as a positional target. Passed authority requires same-child ordered controller receipts: successful open → successful find → successful post-execution cleanup. Only successful find is meaningful; snapshot, goto, screenshot, request/console, and ordinary interactions cannot establish passed. Only when preflight or playwright_cli tool explicitly fails may you write blocked evidence. Always persist {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json (caseId, status passed|failed|blocked, evidencePaths; blocked needs blockedReason). Business failed/blocked is not a node failure. Close via playwright_cli command=close. Return compact JSON (<=1200 chars): {caseId,status,evidencePaths,errorSummary,tokens}.",
234
+ "subtaskPromptTemplate": "Primary job: EXECUTE {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session). Use the structured playwright_cli tool for every browser action. Do not request or search for bash. Translate each playwright-cli line in the case Markdown into one playwright_cli tool call. The controller-owned browser capability freezes baseUrl from allowlisted hash-bound task reference or the localhost default when no URL candidate exists; context/case prose may reference but cannot establish or override that origin. Start only with playwright_cli command=open and controller-injected concrete frozen URL args (default session; no -s=). Dynamic refs: literal eX/eY are documentation placeholders, not tool args. Immediately before every structured playwright_cli call that references an element, parse the actual eNN from the immediately preceding latest snapshot and pass only that actual eNN. Never send literal eX/eY, and never reuse a stale ref after a new snapshot. File outputs are canonical: screenshot uses [--filename, final.png] (or [e5, --filename, final.png] only for a real target), pdf uses [--filename, final.pdf], and snapshot writes a file only with [--filename, snapshot.txt]; snapshot without filename is response-only. Never use --path, --output, --file, or an output path as a positional target. Passed authority requires same-child ordered controller receipts: successful open → successful find → successful post-execution cleanup. Only successful find is meaningful; snapshot, goto, screenshot, request/console, and ordinary interactions cannot establish passed. Only when preflight or playwright_cli tool explicitly fails may you write blocked evidence. Always persist {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json (caseId, status passed|failed|blocked, evidencePaths; blocked needs blockedReason). Business failed/blocked is not a node failure. Close via playwright_cli command=close. Return compact JSON (<=1200 chars): {caseId,status,evidencePaths,errorSummary,tokens}.",
234
235
  "outputContract": "Compact JSON <=1200 chars. Browser actions must use structured playwright_cli tool.",
235
236
  "writePolicy": "exclusive",
236
237
  "allowedPaths": [
@@ -270,7 +271,7 @@
270
271
  ".harness/**",
271
272
  "artifacts/**"
272
273
  ],
273
- "outputContract": "Stdout final JSON line {cases:[...]} for blocked or missing-result-file cases with rerunAttempt < 2.",
274
+ "outputContract": "Round 1 of the bounded rerun topology (default 2 rounds): stdout final JSON line {cases:[...]} for blocked or missing-result-file cases with rerunAttempt < 1; the runtime hybrid generates one selector/map pair per configured maxRerunAttempts so the latest rerun evidence is authoritative.",
274
275
  "subtask_prompt": "Select frontend-test cases eligible for bounded rerun.",
275
276
  "shell": {
276
277
  "commands": [
@@ -294,7 +295,7 @@
294
295
  ".harness/**",
295
296
  "artifacts/**"
296
297
  ],
297
- "outputContract": "Serial rerun of blocked/missing-result frontend cases.",
298
+ "outputContract": "Serial rerun of blocked/missing-result frontend cases; rewrites authoritative case-result.json/execution.md so the final round's result controls reports.",
298
299
  "subtask_prompt": "Expand rerun candidates into serial browser case children.",
299
300
  "static": {
300
301
  "resultMarkdown": "Frontend case map expansion barrier."
@@ -325,7 +326,7 @@
325
326
  ]
326
327
  },
327
328
  "complexity": "MED",
328
- "subtaskPromptTemplate": "RERUN attempt {{case.rerunAttempt}} for {{case.caseId}} (reason={{case.reason}}). Rewrite authoritative evidenceDir case-result.json and execution.md; set rerunAttempt={{case.rerunAttempt}}. Primary job: EXECUTE {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session). Use the structured playwright_cli tool for every browser action. Do not request or search for bash. Translate each playwright-cli line in the case Markdown into one playwright_cli tool call. The controller-owned browser capability freezes baseUrl from hash-bound task source config.md or the localhost default; context/case prose may reference but cannot establish or override that origin. Start only with playwright_cli command=open and controller-injected concrete frozen URL args (default session; no -s=). Dynamic refs: literal eX/eY are documentation placeholders, not tool args. Immediately before every structured playwright_cli call that references an element, parse the actual eNN from the immediately preceding latest snapshot and pass only that actual eNN. Never send literal eX/eY, and never reuse a stale ref after a new snapshot. File outputs are canonical: screenshot uses [--filename, final.png] (or [e5, --filename, final.png] only for a real target), pdf uses [--filename, final.pdf], and snapshot writes a file only with [--filename, snapshot.txt]; snapshot without filename is response-only. Never use --path, --output, --file, or an output path as a positional target. Passed authority requires same-child ordered controller receipts: successful open → successful find → successful post-execution cleanup. Only successful find is meaningful; snapshot, goto, screenshot, request/console, and ordinary interactions cannot establish passed. Only when preflight or playwright_cli tool explicitly fails may you write blocked evidence. Always persist {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json (caseId, status passed|failed|blocked, evidencePaths; blocked needs blockedReason). Business failed/blocked is not a node failure. Close via playwright_cli command=close. Return compact JSON (<=1200 chars): {caseId,status,evidencePaths,errorSummary,tokens}.",
329
+ "subtaskPromptTemplate": "RERUN attempt {{case.rerunAttempt}} for {{case.caseId}} (reason={{case.reason}}). Rewrite authoritative evidenceDir case-result.json and execution.md; set rerunAttempt={{case.rerunAttempt}}; the final rewritten status is authoritative for reporting. Use fixed execution.md headings 执行摘要 and 实际执行步骤 when available. Primary job: EXECUTE {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session). Use the structured playwright_cli tool for every browser action. Do not request or search for bash. Translate each playwright-cli line in the case Markdown into one playwright_cli tool call. The controller-owned browser capability freezes baseUrl from allowlisted hash-bound task reference or the localhost default when no URL candidate exists; context/case prose may reference but cannot establish or override that origin. Start only with playwright_cli command=open and controller-injected concrete frozen URL args (default session; no -s=). Dynamic refs: literal eX/eY are documentation placeholders, not tool args. Immediately before every structured playwright_cli call that references an element, parse the actual eNN from the immediately preceding latest snapshot and pass only that actual eNN. Never send literal eX/eY, and never reuse a stale ref after a new snapshot. File outputs are canonical: screenshot uses [--filename, final.png] (or [e5, --filename, final.png] only for a real target), pdf uses [--filename, final.pdf], and snapshot writes a file only with [--filename, snapshot.txt]; snapshot without filename is response-only. Never use --path, --output, --file, or an output path as a positional target. Passed authority requires same-child ordered controller receipts: successful open → successful find → successful post-execution cleanup. Only successful find is meaningful; snapshot, goto, screenshot, request/console, and ordinary interactions cannot establish passed. Only when preflight or playwright_cli tool explicitly fails may you write blocked evidence. Always persist {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json (caseId, status passed|failed|blocked, evidencePaths; blocked needs blockedReason). Business failed/blocked is not a node failure. Close via playwright_cli command=close. Return compact JSON (<=1200 chars): {caseId,status,evidencePaths,errorSummary,tokens}.",
329
330
  "outputContract": "Compact JSON <=1200 chars. Browser actions must use structured playwright_cli tool.",
330
331
  "writePolicy": "exclusive",
331
332
  "allowedPaths": [
@@ -4,7 +4,7 @@ Write only `testcase/frontend/rag/context.md` and `coverage-map.md`. Record trac
4
4
 
5
5
  ## Controller-frozen Base URL (required)
6
6
 
7
- The preflight controller resolves and freezes one absolute browser base URL from hash-bound task source `config.md` (or the controller default `http://localhost:5173`) before this Pi node runs. Copy that supplied value and source exactly into `context.md` as `baseUrl: <url>` and `baseUrlSource: <source>`.
7
+ The preflight controller resolves and freezes one absolute browser base URL before this Pi node runs. It accepts only controller-owned, hash-bound reference documents on its filename/key allowlists (including `config.md`, `environment.md`, `urls.md` and explicit `baseUrl` / `targetUrl` / `loginUrl` variants); it fails closed on conflicting candidates and uses the localhost default only when no candidate exists. Copy the supplied value and source exactly into `context.md` as `baseUrl: <url>` and `baseUrlSource: <source>`.
8
8
 
9
9
  - Do not derive, replace, or override the origin from model reasoning, route text, case prose, existing RAG files, or other repository content.
10
10
  - Never use production hosts or credentials.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.33.2",
3
+ "version": "0.33.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",
@@ -80,6 +80,13 @@ controller receipts:successful `open` → successful `find` → successful pos
80
80
  - 文件型证据:`screenshot` 与 `pdf` 必须使用 `--filename <file>`,且文件名由 controller 规范化到当前 case evidence 目录。截图一律写为 `playwright-cli screenshot --filename final.png`;有真实元素 ref 时写为 `playwright-cli screenshot e5 --filename final.png`。`pdf` 写为 `playwright-cli pdf --filename final.pdf`。`snapshot` 不带 filename 时只返回响应;需要文件时写为 `playwright-cli snapshot --filename snapshot.txt`。不得使用 `--path`、`--output`、`--file`,也不得把文件路径作为位置参数。
81
81
  - 对话框、上传、拖放和视口变化仅在 case 已给出所需前置条件时使用对应 allowlist 命令。
82
82
 
83
+ ## 持久化执行事实
84
+
85
+ 每个 browser case 必须在自身 evidenceDir 写入权威的 `execution.md` 与 `case-result.json`,最终报告只读取最后一次写入的文件:
86
+
87
+ - `case-result.json`:包含匹配的 `caseId`、最终 `status`(`passed`/`failed`/`blocked`)、`evidencePaths`;重跑时用整数 `rerunAttempt` 覆写同一文件,最终状态即报告状态。
88
+ - `execution.md`:当存在可记录的执行事实时,使用固定标题 `### 执行摘要` 与 `### 实际执行步骤`,以便有界确定性报告抽取器展示实际执行步骤与摘要。这两段为可选;缺失只形成 advisory,不会降级已通过状态。
89
+
83
90
  ## 阻塞处理
84
91
 
85
92
  当环境、数据、权限、时序、异常路径或响应内容无法用 RAG、冻结配置和 runtime allowlist 证明时,