@tea-agent/loop-agent 0.33.2 → 0.33.3
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 +14 -0
- package/dist/workflows/dag/frontend-test-html-report.js +38 -23
- package/dist/workflows/dag/frontend-test-result-contract.js +77 -6
- package/dist/workflows/dag/init-hybrid.js +150 -50
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.json +11 -10
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
- package/package.json +1 -1
- package/skills/playwright-cli/SKILL.md +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.33.3] - 2026-08-10
|
|
6
|
+
|
|
7
|
+
### 重点更新
|
|
8
|
+
|
|
9
|
+
- 修复 Windows 环境下 frontend-test 配置 URL 冻结异常的问题,确保跨平台地址识别行为一致
|
|
10
|
+
|
|
11
|
+
### 改进
|
|
12
|
+
|
|
13
|
+
- 改进 frontend-test 的 Windows URL 回归测试,使其具备更好的跨平台可移植性
|
|
14
|
+
|
|
15
|
+
### 修复
|
|
16
|
+
|
|
17
|
+
- 修复 Windows 环境下 frontend-test 配置 URL(如 baseUrl、targetUrl、loginUrl)无法正确冻结的问题,冲突候选现安全 fail closed,仅在完全未配置时回退 localhost
|
|
18
|
+
|
|
5
19
|
## [0.33.2] - 2026-08-10
|
|
6
20
|
|
|
7
21
|
### 重点更新
|
|
@@ -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
|
|
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="
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const testPointsHtml =
|
|
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
|
-
|
|
92
|
-
""
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
|
3948
|
-
.
|
|
3949
|
-
.
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
const
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
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
|
-
|
|
4024
|
+
candidates.push({
|
|
4025
|
+
url: rawUrl,
|
|
4026
|
+
source,
|
|
4027
|
+
keyName: normalizedKey,
|
|
4028
|
+
priority,
|
|
4029
|
+
documentIndex,
|
|
4030
|
+
});
|
|
3962
4031
|
}
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
4405
|
-
depends_on: [
|
|
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:
|
|
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:
|
|
4429
|
-
depends_on: [
|
|
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:
|
|
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:
|
|
4444
|
-
itemsFrom:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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": "
|
|
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
|
|
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": "
|
|
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
|
|
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
|
|
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
|
@@ -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 证明时,
|