@tea-agent/loop-agent 0.44.0-next.10 → 0.44.0-next.12
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 +11 -0
- package/dist/build-stamp.json +3 -3
- package/dist/executors/dag-pi/sessions/index.js +6 -0
- package/dist/executors/dag-pi/sessions/plan-batches.js +280 -0
- package/dist/executors/dag-pi/sessions/plan-prompts.js +367 -0
- package/dist/executors/dag-pi/sessions/scout-parallel.js +197 -0
- package/dist/executors/dag-pi/sessions/segmented-plan.js +1184 -0
- package/dist/executors/dag-pi/sessions/writer-evidence.js +60 -0
- package/dist/executors/dag-pi-executor.js +12 -2075
- package/dist/executors/shell-executor.js +39 -11
- package/dist/worker/console/routes.js +5 -0
- package/dist/worker/console/static/app-icon.svg +39 -0
- package/dist/worker/console/static/index.html +3 -0
- package/dist/worker/console/static/manifest.webmanifest +19 -0
- package/dist/worker/observe/static/console-theme.js +38 -0
- package/dist/workflows/dag/checkpoint.js +686 -0
- package/dist/workflows/dag/frontend-repair.js +24 -0
- package/dist/workflows/dag/frontend-review-scopes.js +10 -2
- package/dist/workflows/dag/frontend-test-execution-evidence.js +163 -67
- package/dist/workflows/dag/frontend-test-framework-adapters.js +309 -0
- package/dist/workflows/dag/hybrid/sources.js +1812 -0
- package/dist/workflows/dag/hybrid/templates/backend-test.js +1807 -0
- package/dist/workflows/dag/hybrid/templates/frontend-test.js +702 -0
- package/dist/workflows/dag/hybrid/templates/frontend.js +1529 -0
- package/dist/workflows/dag/hybrid/templates/index.js +8 -0
- package/dist/workflows/dag/hybrid/templates/kg-bootstrap.js +449 -0
- package/dist/workflows/dag/hybrid/templates/knowledge-sync.js +495 -0
- package/dist/workflows/dag/hybrid/templates/shared.js +101 -0
- package/dist/workflows/dag/hybrid/templates/standard.js +265 -0
- package/dist/workflows/dag/hybrid/types.js +32 -0
- package/dist/workflows/dag/init-hybrid.js +42 -7162
- package/dist/workflows/dag/runner.js +11 -1178
- package/dist/workflows/dag/terminal-status.js +502 -0
- package/docs/architecture/dag-execution.md +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
/** DAG hybrid frontend-test 模板族:串行浏览器 RAG DAG 与 frontend-test 布局应用。 */
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { assertValidDagSpec } from "../../validate.js";
|
|
5
|
+
import { DEFAULT_DAG_EXECUTOR_MODELS, DEFAULT_DAG_OUTPUT_LANGUAGE, parseDagSpec } from "../../types.js";
|
|
6
|
+
import { pathMatchesPattern } from "../../../../shared/git-progress.js";
|
|
7
|
+
import { applyFrontendTestLayoutToText, resolveFrontendTestLayout } from "../../frontend-test-layout.js";
|
|
8
|
+
import { buildFrontendTestOutcomeGateShellSnippet } from "../../frontend-test-result-contract.js";
|
|
9
|
+
import { GENERATED_DAG_RUNTIME_CONTRACT, HYBRID_DEFAULTS, STANDARD_GLOBAL_CONSTRAINTS, buildDagSourceBinding, buildSourceContextBlock, extractObjective, extractSuccessCriteria, resolveDagVerifyStrategy } from "../sources.js";
|
|
10
|
+
import { applyDefaultReadOnlyRetryPolicy, commonForbiddenPaths, commonReadOnlyPaths, stampGeneratedArtifactBindings, } from "./shared.js";
|
|
11
|
+
export function buildFrontendTestHybridDag(sources) {
|
|
12
|
+
const rawFrontendTest = sources.taskConfig.frontendTest;
|
|
13
|
+
const layout = resolveFrontendTestLayout(rawFrontendTest);
|
|
14
|
+
const config = {
|
|
15
|
+
// Default 32: common FE suites cover ~24 AC with multi-dimension cases; 20 caused map maxExpandedNodes failures.
|
|
16
|
+
maxCasesPerBatch: rawFrontendTest?.maxCasesPerBatch ?? 32,
|
|
17
|
+
maxTokensPerCase: rawFrontendTest?.maxTokensPerCase,
|
|
18
|
+
maxTotalTokens: rawFrontendTest?.maxTotalTokens,
|
|
19
|
+
reviewMode: rawFrontendTest?.reviewMode ?? "off",
|
|
20
|
+
strictOutcomeGate: rawFrontendTest?.strictOutcomeGate === true,
|
|
21
|
+
maxRerunAttempts: (() => {
|
|
22
|
+
const raw = rawFrontendTest?.maxRerunAttempts;
|
|
23
|
+
if (raw === undefined || raw === null || Number.isNaN(Number(raw))) {
|
|
24
|
+
return rawFrontendTest?.reviewMode === "blocking" ? 2 : 1;
|
|
25
|
+
}
|
|
26
|
+
return Math.min(4, Math.max(0, Math.trunc(Number(raw))));
|
|
27
|
+
})(),
|
|
28
|
+
reports: {
|
|
29
|
+
retrospect: rawFrontendTest?.reports?.retrospect === true,
|
|
30
|
+
l5: rawFrontendTest?.reports?.l5 !== false,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const declaredRequirementIds = buildDagSourceBinding(sources).requirementIds;
|
|
34
|
+
const declaredAcIds = declaredRequirementIds.filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
|
|
35
|
+
// Optional project-local UI anchor ledger: only mention it in prompts when it
|
|
36
|
+
// exists so ledger-less projects keep generating without noise.
|
|
37
|
+
const hasUiAnchorsLedger = Boolean(sources.repoRoot &&
|
|
38
|
+
existsSync(path.join(sources.repoRoot, layout.ragDir, "ui-anchors.md")));
|
|
39
|
+
const uiAnchorsLedgerInstruction = hasUiAnchorsLedger
|
|
40
|
+
? `Also read ${layout.ragDir}/ui-anchors.md (UI anchor ledger). Every passing find assertion in generated cases must quote a ledger row whose 状态 is 有效 for the matching page x state section; rows marked 不可断言/失效 must not be used as passing assertions. When an AC names a control absent from the ledger section for that state, emit a blocked case note (blockedReason unique-control-unavailable) instead of exploratory find steps, and follow ledger 备注 alternatives (split-node short literals) exactly.`
|
|
41
|
+
: "";
|
|
42
|
+
const reviewMode = config.reviewMode;
|
|
43
|
+
const blockingReview = reviewMode === "blocking";
|
|
44
|
+
const strictOutcomeGate = config.strictOutcomeGate;
|
|
45
|
+
const maxRerunAttempts = config.maxRerunAttempts;
|
|
46
|
+
const enableRetrospect = config.reports?.retrospect === true;
|
|
47
|
+
const enableL5Report = config.reports?.l5 !== false;
|
|
48
|
+
if (!allowedPathsCoverFrontendTestRoot(sources.taskConfig.allowedPaths, layout.testRoot)) {
|
|
49
|
+
throw new Error(`frontend-test requires task.json allowedPaths to cover "${layout.testRoot}/**" (or an explicit containing glob).`);
|
|
50
|
+
}
|
|
51
|
+
const forbidden = commonForbiddenPaths(sources);
|
|
52
|
+
const ragWriteSet = [`${layout.ragDir}/**`];
|
|
53
|
+
const caseDraftWriteSet = [
|
|
54
|
+
`${layout.casesDir}/FE-*.md`,
|
|
55
|
+
`${layout.casesDir}/index.md`,
|
|
56
|
+
`${layout.casesDir}/manifest.draft.json`,
|
|
57
|
+
];
|
|
58
|
+
// The shell materializer alone owns the final manifest boundary.
|
|
59
|
+
const casesWriteSet = [`${layout.casesDir}/**`];
|
|
60
|
+
const evidenceRoot = layout.evidenceDir;
|
|
61
|
+
const declaredAcIdsLiteral = JSON.stringify(declaredAcIds);
|
|
62
|
+
const maxCasesPerBatchLiteral = String(config.maxCasesPerBatch);
|
|
63
|
+
const checklistScript = [
|
|
64
|
+
"const fs=require('fs'),path=require('path');",
|
|
65
|
+
"const root='testcase/frontend/cases';",
|
|
66
|
+
"const draft=path.join(root,'manifest.draft.json');",
|
|
67
|
+
"const final=path.join(root,'manifest.json');",
|
|
68
|
+
"const manifestPath=fs.existsSync(draft)?draft:(fs.existsSync(final)?final:null);",
|
|
69
|
+
"if(!manifestPath)throw new Error('checklist: missing manifest.draft.json or manifest.json');",
|
|
70
|
+
"const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));",
|
|
71
|
+
"if(!Array.isArray(manifest.cases)||manifest.cases.length===0)throw new Error('checklist: empty cases');",
|
|
72
|
+
`const declaredAc=new Set(${declaredAcIdsLiteral});`,
|
|
73
|
+
"const issues=[];",
|
|
74
|
+
"const openRe=/playwright-cli\\s+open\\s+--browser=chrome\\s+https?:\\/\\/\\S+/i;",
|
|
75
|
+
"const prodRe=/(?:^|\\/\\/)(?:www\\.)?[^\\s\\/]*(?:prod|production)/i;",
|
|
76
|
+
"const caseIdRe=/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;",
|
|
77
|
+
,
|
|
78
|
+
"const acIdRe=/^AC(?:-[A-Z0-9]+)+$/i;",
|
|
79
|
+
"const truncatedAcRe=/^AC-FE-[A-Z]+$/i;",
|
|
80
|
+
"const truncatedUndeclaredAcRe=/^(?:AC-\\d{3}|AC-FE-[A-Z]+)$/i;",
|
|
81
|
+
"for(const c of manifest.cases){",
|
|
82
|
+
" const id=c&&c.caseId||'?';",
|
|
83
|
+
" if(typeof c.caseId!=='string'||!caseIdRe.test(c.caseId))issues.push({ruleId:'case-id-shape',caseId:id,detail:'caseId must be FE-<FEATURE>-<NNN>-<dimension>, never AC-FE-*'});",
|
|
84
|
+
" if(typeof c.caseId==='string'&&/^AC-/i.test(c.caseId))issues.push({ruleId:'case-id-is-ac',caseId:id,detail:'do not use acceptance id as caseId; put AC-FE-* only in acIds'});",
|
|
85
|
+
" const casePath=typeof c.casePath==='string'?c.casePath:null;",
|
|
86
|
+
" if(!casePath||!fs.existsSync(casePath)){issues.push({ruleId:'case-file-missing',caseId:id,detail:String(casePath)});continue;}",
|
|
87
|
+
" if(typeof c.caseId==='string'&&casePath!=='testcase/frontend/cases/'+c.caseId+'.md')issues.push({ruleId:'case-path-mismatch',caseId:id,detail:casePath+' must equal testcase/frontend/cases/'+c.caseId+'.md'});",
|
|
88
|
+
" const body=fs.readFileSync(casePath,'utf8');",
|
|
89
|
+
" if(!openRe.test(body))issues.push({ruleId:'open-prefix',caseId:id,detail:'missing playwright-cli open --browser=chrome <absolute-url>; playwright-cli is strongly recommended for browser execution'});",
|
|
90
|
+
,
|
|
91
|
+
" const m=body.match(/playwright-cli\\s+open\\s+--browser=chrome\\s+(https?:\\/\\/\\S+)/i);",
|
|
92
|
+
" if(m){const url=m[1].replace(/[)\\]},.\"']+$/,''); if(prodRe.test(url))issues.push({ruleId:'production-url',caseId:id,detail:url});}",
|
|
93
|
+
" if(!Array.isArray(c.acIds)||c.acIds.length===0)issues.push({ruleId:'ac-mapping',caseId:id,detail:'acIds required (AC-FE-* acceptance ids, not caseId)'});",
|
|
94
|
+
" else {",
|
|
95
|
+
" for(const ac of c.acIds){",
|
|
96
|
+
" if(typeof ac!=='string'){issues.push({ruleId:'ac-id-shape',caseId:id,detail:String(ac)+' must look like AC-FE-001'});continue;}",
|
|
97
|
+
" if(/^FE-/i.test(ac)){issues.push({ruleId:'ac-id-is-case',caseId:id,detail:ac+' looks like caseId; acIds must be AC-*'});continue;}",
|
|
98
|
+
" if(!acIdRe.test(ac)){issues.push({ruleId:'ac-id-shape',caseId:id,detail:ac+' must look like AC-FE-001'});continue;}",
|
|
99
|
+
" if(truncatedAcRe.test(ac)||(declaredAc.size===0&&truncatedUndeclaredAcRe.test(ac))){issues.push({ruleId:'ac-id-truncated',caseId:id,detail:ac+' is a truncated acceptance id (missing feature prefix or sequence number)'});continue;}",
|
|
100
|
+
" if(declaredAc.size>0&&!declaredAc.has(ac))issues.push({ruleId:'unknown-ac',caseId:id,detail:ac+' not in task sourceBinding.requirementIds'});",
|
|
101
|
+
" }",
|
|
102
|
+
" }",
|
|
103
|
+
"}",
|
|
104
|
+
"if(issues.length){console.error('frontend-test checklist blocked: '+JSON.stringify(issues)); process.exit(1);}",
|
|
105
|
+
"console.log('frontend-test checklist ok cases='+manifest.cases.length+' source='+path.basename(manifestPath));",
|
|
106
|
+
].join("");
|
|
107
|
+
// Pass generated JavaScript as base64 rather than embedding it inside a
|
|
108
|
+
// shell-quoted `node -e` argument. Git Bash otherwise consumes backslashes
|
|
109
|
+
// such as \\s/\\d and interprets Markdown backticks before Node sees them.
|
|
110
|
+
const checklistScriptBase64 = Buffer.from(checklistScript, "utf8").toString("base64");
|
|
111
|
+
const checklistValidation = [
|
|
112
|
+
"node -e \"require('node:vm').runInThisContext(Buffer.from(process.argv[1],'base64').toString('utf8'))\"",
|
|
113
|
+
checklistScriptBase64,
|
|
114
|
+
].join(" ");
|
|
115
|
+
const manifestValidation = [
|
|
116
|
+
"node -e",
|
|
117
|
+
JSON.stringify([
|
|
118
|
+
"const fs=require('fs'),path=require('path');",
|
|
119
|
+
"function fail(ruleId,detail){ const msg=JSON.stringify({ruleId:ruleId,detail:String(detail||'')}); console.error('frontend-test manifest blocked: '+msg); throw new Error('frontend-test manifest blocked: '+ruleId+(detail?(': '+detail):'')); }",
|
|
120
|
+
"const draft='testcase/frontend/cases/manifest.draft.json',file='testcase/frontend/cases/manifest.json',tmp=file+'.tmp';",
|
|
121
|
+
"if(!fs.existsSync(draft)) fail('draft-missing','missing '+draft);",
|
|
122
|
+
"let manifest; try{manifest=JSON.parse(fs.readFileSync(draft,'utf8'));}catch(e){fail('draft-invalid-json',e&&e.message||e);}",
|
|
123
|
+
"if(manifest.schemaVersion!==1) fail('draft-schema','schemaVersion must be 1');",
|
|
124
|
+
"if(!Array.isArray(manifest.cases)||manifest.cases.length===0) fail('draft-empty-cases','cases must be a non-empty array');",
|
|
125
|
+
`const maxCases=${maxCasesPerBatchLiteral};`,
|
|
126
|
+
"if(manifest.cases.length>maxCases) fail('map-capacity-exceeded','cases='+manifest.cases.length+' exceeds maxCasesPerBatch/maxExpandedNodes='+maxCases+'; raise frontendTest.maxCasesPerBatch or shrink the suite');",
|
|
127
|
+
"const dims=new Set(['core','boundary','flow','backend']);",
|
|
128
|
+
`const declaredAc=new Set(${declaredAcIdsLiteral});`,
|
|
129
|
+
"const seen=new Set(); const seenCasePath=new Set(); const seenEvidenceDir=new Set();",
|
|
130
|
+
"const acIdRe=/^AC(?:-[A-Z0-9]+)+$/i;",
|
|
131
|
+
"const truncatedAcRe=/^AC-FE-[A-Z]+$/i;",
|
|
132
|
+
"const truncatedUndeclaredAcRe=/^(?:AC-\\d{3}|AC-FE-[A-Z]+)$/i;",
|
|
133
|
+
"for(const c of manifest.cases){",
|
|
134
|
+
" if(!c||typeof c.caseId!=='string'||!/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/.test(c.caseId)) fail('case-id-shape','caseId must be FE-*, never AC-FE-*: '+String(c&&c.caseId));",
|
|
135
|
+
" if(/^AC-/i.test(c.caseId)) fail('case-id-is-ac','caseId must not be an acceptance id: '+c.caseId);",
|
|
136
|
+
" if(seen.has(c.caseId)) fail('duplicate-case-id',c.caseId);",
|
|
137
|
+
" seen.add(c.caseId);",
|
|
138
|
+
" if(typeof c.dimension!=='string'||!dims.has(c.dimension)) fail('invalid-dimension',String(c.dimension));",
|
|
139
|
+
" if(!Array.isArray(c.acIds)||c.acIds.length===0||c.acIds.some(a=>typeof a!=='string'||!a.trim())) fail('ac-mapping','invalid acIds for '+c.caseId);",
|
|
140
|
+
" for(const ac of c.acIds){ if(!acIdRe.test(ac)) fail('ac-id-shape','acIds entry must be AC-* acceptance id, not caseId: '+ac); if(truncatedAcRe.test(ac)||(declaredAc.size===0&&truncatedUndeclaredAcRe.test(ac))) fail('ac-id-truncated','acIds entry is a truncated acceptance id (missing feature prefix or sequence number): '+ac); if(declaredAc.size>0&&!declaredAc.has(ac)) fail('unknown-ac',ac+' not in sourceBinding; repair generator input or AC list'); }",
|
|
141
|
+
" c.casePath='testcase/frontend/cases/'+c.caseId+'.md';",
|
|
142
|
+
" c.evidenceDir='testcase/frontend/evidence/'+c.caseId+'/';",
|
|
143
|
+
" for(const k of ['casePath','evidenceDir']){ const v=c[k]; if(typeof v!=='string'||path.isAbsolute(v)||v.includes('..')) fail('unsafe-path',k+': '+String(v)); }",
|
|
144
|
+
" if(!fs.existsSync(c.casePath)) fail('case-file-missing','missing case file '+c.casePath+' (filename must equal caseId.md)');",
|
|
145
|
+
" if(seenCasePath.has(c.casePath)) fail('duplicate-case-path',c.casePath); seenCasePath.add(c.casePath);",
|
|
146
|
+
" if(seenEvidenceDir.has(c.evidenceDir)) fail('duplicate-evidence-dir',c.evidenceDir); seenEvidenceDir.add(c.evidenceDir);",
|
|
147
|
+
"}",
|
|
148
|
+
"const payload=JSON.stringify(manifest,null,2)+'\\n';",
|
|
149
|
+
"fs.writeFileSync(tmp,payload); fs.renameSync(tmp,file); try{fs.unlinkSync(draft);}catch(_){}",
|
|
150
|
+
"process.stdout.write(JSON.stringify({cases:manifest.cases}));",
|
|
151
|
+
].join("")),
|
|
152
|
+
].join(" ");
|
|
153
|
+
const frontendTestOutcomeGate = buildFrontendTestOutcomeGateShellSnippet();
|
|
154
|
+
const frontendCaseQualityAdvisory = [
|
|
155
|
+
"node -e",
|
|
156
|
+
JSON.stringify([
|
|
157
|
+
"const fs=require('fs'),path=require('path');",
|
|
158
|
+
"const runDir=process.env.HARNESS_DAG_RUN_DIR; if(!runDir)throw new Error('missing HARNESS_DAG_RUN_DIR');",
|
|
159
|
+
"const resultPath=path.join(runDir,'contracts','frontend-test-result.json'); if(!fs.existsSync(resultPath))throw new Error('missing '+resultPath);",
|
|
160
|
+
"const r=JSON.parse(fs.readFileSync(resultPath,'utf8')); const findings=Array.isArray(r.advisoryFindings)?r.advisoryFindings:[];",
|
|
161
|
+
"const reports='testcase/frontend/reports'; fs.mkdirSync(reports,{recursive:true});",
|
|
162
|
+
"const esc=v=>String(v??'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('\\\"','"');",
|
|
163
|
+
"const labels={cases:'用例总数',passed:'通过',failed:'失败',blocked:'阻塞'}; const totals=r.totals||{}; const missing=r.acceptanceCoverage&&Array.isArray(r.acceptanceCoverage.missing)?r.acceptanceCoverage.missing:[];",
|
|
164
|
+
"const md=['# 前端测试质量建议报告','','> Advisory:以下 finding 用于改进测试资产和证据质量,不阻塞后续流程。','','## 执行摘要','',...Object.entries(labels).map(([k,v])=>'- '+v+': '+Number(totals[k]||0)),'- 执行结果: '+String(r.outcome||'unknown'),'- 缺失 AC: '+(missing.join(', ')||'无'),'','## 建议项','',...(findings.length?findings.map(f=>'- ['+f.ruleId+']'+(f.caseId?' '+f.caseId:'')+': '+f.detail):['- 未发现建议项。']),''];",
|
|
165
|
+
"fs.writeFileSync(path.join(reports,'frontend-test-case-quality-advisory.md'),md.join('\\n'));",
|
|
166
|
+
"const rows=findings.length?findings.map(f=>'<tr><td><code>'+esc(f.ruleId)+'</code></td><td>'+esc(f.caseId||'-')+'</td><td>'+esc(f.detail)+'</td></tr>').join(''):'<tr><td colspan=3>未发现建议项。</td></tr>';",
|
|
167
|
+
"const html='<!doctype html><html lang=\\\"zh-CN\\\"><head><meta charset=\\\"utf-8\\\"><meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1\\\"><title>前端测试质量建议报告</title><style>body{font:16px system-ui,Microsoft YaHei,sans-serif;background:#f5f7fb;color:#172033;margin:0;padding:32px}main{max-width:1100px;margin:auto;background:#fff;padding:32px;border-radius:16px}.badge{display:inline-block;padding:6px 10px;border-radius:999px;background:#fff3cd;color:#946200}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.card{padding:16px;background:#f8fafc;border-radius:10px}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px;border-bottom:1px solid #e5e7eb}code{color:#475467}@media(max-width:700px){.grid{grid-template-columns:1fr 1fr}}</style></head><body><main><h1>前端测试质量建议报告</h1><p><span class=\\\"badge\\\">Advisory,不阻塞后续流程</span></p><h2>执行摘要</h2><div class=\\\"grid\\\">'+Object.entries(labels).map(([k,v])=>'<div class=\\\"card\\\"><strong>'+v+'</strong><div>'+Number(totals[k]||0)+'</div></div>').join('')+'</div><p>执行结果:'+esc(r.outcome||'unknown')+';缺失 AC:'+esc(missing.join(', ')||'无')+'</p><h2>建议项</h2><table><thead><tr><th>规则</th><th>Case ID</th><th>说明与建议</th></tr></thead><tbody>'+rows+'</tbody></table></main></body></html>';",
|
|
168
|
+
"fs.writeFileSync(path.join(reports,'frontend-test-case-quality-advisory.html'),html); console.log('frontend-test advisory report findings='+findings.length);",
|
|
169
|
+
].join("")),
|
|
170
|
+
].join(" ");
|
|
171
|
+
const tasks = [
|
|
172
|
+
{
|
|
173
|
+
id: "preflight-frontend-browser-tool-shell",
|
|
174
|
+
depends_on: [],
|
|
175
|
+
role: "verifier",
|
|
176
|
+
executor: "shell",
|
|
177
|
+
complexity: "LOW",
|
|
178
|
+
writePolicy: "read-only",
|
|
179
|
+
allowedPaths: [],
|
|
180
|
+
forbiddenPaths: forbidden,
|
|
181
|
+
outputContract: "Deterministic browser-tool preflight: SDK-only custom-tool capability + controller verified playwright-cli launcher + --help contract + frozen controller origin. Fail closed with playwright-cli-unavailable | playwright-cli-contract-incompatible | browser-command-capability-unavailable before any frontend-test Pi node.",
|
|
182
|
+
subtask_prompt: "Verify CODE_AGENT_PI_BACKEND permits SDK, the Pi SDK structured custom-tool surface is available, and the controller-resolved playwright-cli launcher and --help list open/close/find/snapshot/click. Do not resolve or freeze the target URL in this node. Do not install packages. Do not start a browser session. On failure exit non-zero so retrieve/generate/map never run (zero frontend-test Pi calls).",
|
|
183
|
+
shell: {
|
|
184
|
+
commands: [],
|
|
185
|
+
frontendBrowserToolPreflight: {},
|
|
186
|
+
cwd: ".",
|
|
187
|
+
timeoutMs: 60000,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: "prepare-frontend-test-package-shell",
|
|
192
|
+
depends_on: ["preflight-frontend-browser-tool-shell"],
|
|
193
|
+
role: "verifier",
|
|
194
|
+
executor: "shell",
|
|
195
|
+
complexity: "LOW",
|
|
196
|
+
writePolicy: "exclusive",
|
|
197
|
+
writeSet: ragWriteSet,
|
|
198
|
+
allowedPaths: [...ragWriteSet],
|
|
199
|
+
forbiddenPaths: forbidden,
|
|
200
|
+
outputContract: "Write testcase/frontend/rag/standard-scenarios.v1.json for generate-time standard scenario coverage. Copy docs/templates or harness.json governanceRoot templates (including ai_workspace/loop-agent/templates) when present; otherwise write the minimal STD-FE-SMOKE-ENTRY fallback.",
|
|
201
|
+
subtask_prompt: "Prepare frontend-test package: materialize standard-scenarios.v1.json into the RAG package from docs/templates, governanceRoot/templates, or the init-projected ai_workspace/loop-agent/templates path.",
|
|
202
|
+
shell: {
|
|
203
|
+
commands: [],
|
|
204
|
+
frontendTestStandardScenarios: {},
|
|
205
|
+
cwd: ".",
|
|
206
|
+
timeoutMs: 60_000,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "retrieve-frontend-test-context-pi",
|
|
211
|
+
depends_on: ["prepare-frontend-test-package-shell"],
|
|
212
|
+
role: "planner",
|
|
213
|
+
executor: "pi",
|
|
214
|
+
toolProfile: "write",
|
|
215
|
+
complexity: "MED",
|
|
216
|
+
writePolicy: "exclusive",
|
|
217
|
+
writeGuardPolicy: "tools-only",
|
|
218
|
+
writeSet: ragWriteSet,
|
|
219
|
+
allowedPaths: [...commonReadOnlyPaths(sources), ...ragWriteSet],
|
|
220
|
+
forbiddenPaths: forbidden,
|
|
221
|
+
outputContract: "Write short testcase/frontend/rag/context.md and coverage-map.md with a model-resolved non-production baseUrl/baseUrlSource from task source config, environmentProbe=pending, and capability notes.",
|
|
222
|
+
subtask_prompt: [
|
|
223
|
+
"Build the frontend test RAG package (keep it short).",
|
|
224
|
+
"Read task source, routes/components/API or Mock facts, and execution contract. Write only testcase/frontend/rag/context.md and coverage-map.md.",
|
|
225
|
+
"Prefer fixed fields: baseUrl, baseUrlSource, environmentProbe, AC table, capability matrix (backend real/mock, pagination data, HTTP observation, error injection), risks, forbidden hosts. Do not paste large implementation dumps.",
|
|
226
|
+
"Base URL resolution (required): read the bound task source and config/environment references. Prefer TARGET_URL/targetUrl as the concrete tested entry, then BASE_URL/baseUrl or FRONTEND_BASE_URL/frontendUrl, then LOGIN_URL/loginUrl; accept common casing and underscore/kebab/space variants. Choose one absolute non-production http(s) URL, never API-only or production hosts. If no usable URL exists, use http://localhost:5173. Write `baseUrl: <url>`, `baseUrlSource: <bound source path>|default-localhost-5173`, and `environmentProbe: pending`. Include exact start prefix: playwright-cli open --browser=chrome <resolved-base-url> with the concrete selected URL.",
|
|
227
|
+
buildSourceContextBlock(sources),
|
|
228
|
+
].join("\n\n"),
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
id: "materialize-frontend-test-execution-shell",
|
|
232
|
+
depends_on: ["retrieve-frontend-test-context-pi"],
|
|
233
|
+
role: "verifier",
|
|
234
|
+
executor: "shell",
|
|
235
|
+
complexity: "LOW",
|
|
236
|
+
writePolicy: "exclusive",
|
|
237
|
+
writeSet: ragWriteSet,
|
|
238
|
+
allowedPaths: [...ragWriteSet],
|
|
239
|
+
forbiddenPaths: forbidden,
|
|
240
|
+
outputContract: "Fail-closed environment preflight: absolute non-production baseUrl + curl HTTP reachability; writes environmentProbe facts; unreachable => blockedReason frontend-base-url-unreachable with errorClass (connection-refused / dns-unresolved / connect-timeout / http-N / curl-exit-N). Node ERROR so generate/map do not run. Does not start the app.",
|
|
241
|
+
subtask_prompt: "Parse the resolved absolute baseUrl from testcase/frontend/rag/context.md, reject production/non-http(s)/credential/query/fragment URLs, then probe it with curl HEAD and GET fallback (connect/max-time; no auth/cookie). 2xx/3xx => reachable and continue. Connection refused (curl 7) records errorClass=connection-refused and tells the operator to start the local app (scripts/serve.sh or npm start) then rerun from this node. 4xx/5xx/DNS/timeout/TLS => blockedReason frontend-base-url-unreachable with a distinct errorClass. Missing curl => blockedReason curl-unavailable. Do not start the app. Fixture/reset remain soft guidance.",
|
|
242
|
+
shell: {
|
|
243
|
+
commands: [],
|
|
244
|
+
frontendTestEnvironmentProbe: {},
|
|
245
|
+
cwd: ".",
|
|
246
|
+
timeoutMs: 60000,
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: "generate-frontend-functional-cases-pi",
|
|
251
|
+
depends_on: ["materialize-frontend-test-execution-shell"],
|
|
252
|
+
role: "implementer",
|
|
253
|
+
executor: "pi",
|
|
254
|
+
toolProfile: "write",
|
|
255
|
+
complexity: "HIGH",
|
|
256
|
+
writePolicy: "exclusive",
|
|
257
|
+
writeGuardPolicy: "tools-only",
|
|
258
|
+
writeSet: caseDraftWriteSet,
|
|
259
|
+
allowedPaths: [...ragWriteSet, ...caseDraftWriteSet],
|
|
260
|
+
forbiddenPaths: forbidden,
|
|
261
|
+
outputContract: "Write executable Markdown frontend cases, index.md, and manifest.draft.json schemaVersion 1 only; the exclusive shell materializer promotes the validated draft to manifest.json. Do not write manifest.json or test source code.",
|
|
262
|
+
subtask_prompt: [
|
|
263
|
+
"Use skill playwright-cli-case-generator.",
|
|
264
|
+
"Read testcase/frontend/rag/standard-scenarios.v1.json and cover priority=must scenarios (or record GAP in coverage-map). Include ## 测试点 and ## 测试步骤 in each case.",
|
|
265
|
+
...(uiAnchorsLedgerInstruction ? [uiAnchorsLedgerInstruction] : []),
|
|
266
|
+
"Read only testcase/frontend/rag/context.md, testcase/frontend/rag/coverage-map.md, and the draft case paths testcase/frontend/cases/FE-*.md, testcase/frontend/cases/index.md, and testcase/frontend/cases/manifest.draft.json. Write only those same draft paths. Do not write testcase/frontend/cases/manifest.json.",
|
|
267
|
+
"Generate Markdown cases, index.md and manifest.draft.json (schemaVersion 1; cases[] with caseId, casePath, dimension, acIds, evidenceDir).",
|
|
268
|
+
"HARD ID CONTRACT (do not confuse these):",
|
|
269
|
+
"- caseId / filename MUST be FE-<FEATURE>-<NNN>-<dimension> (example FE-LOGIN-001-core). NEVER use AC-FE-* as caseId or filename.",
|
|
270
|
+
"- acIds MUST list acceptance criteria only: AC-FE-* / AC-* from the declared task list (example AC-FE-001). NEVER put FE-* case ids into acIds.",
|
|
271
|
+
"- casePath MUST equal testcase/frontend/cases/<caseId>.md; evidenceDir MUST equal testcase/frontend/evidence/<caseId>/. Materialize will rewrite paths, but files must already use caseId filenames.",
|
|
272
|
+
`Declared acceptance ids for this task (use only these in acIds when non-empty): ${declaredAcIds.length > 0 ? declaredAcIds.join(", ") : "(none extracted - still use AC-* shape, never FE-* case ids)"}.`,
|
|
273
|
+
"dimensions: core|boundary|flow|backend only.",
|
|
274
|
+
"Prefer a small smoke suite (default max roughly 4-8 cases unless task frontendTest.maxCasesPerBatch is higher). Never invent unavailable API fields or credentials. Do not create pytest or Playwright source.",
|
|
275
|
+
"HARD playwright-cli-only: every browser step must use repo skill playwright-cli declared commands only. Forbidden: bare `playwright`, `npx playwright`, `playwright test`, `@playwright/test`, Node Playwright API, or generating Playwright/Pytest source. No fallback when playwright-cli is unavailable - case must instruct blocked evidence playwright-cli-unavailable.",
|
|
276
|
+
"Copy the resolved absolute baseUrl from testcase/frontend/rag/context.md after the environment probe has marked it reachable. Every browser start command must be exactly: playwright-cli open --browser=chrome <resolved-base-url-from-context.md> with that concrete URL. Never leave a base-url placeholder. Use default browser session only; never write -s=<case-id>.",
|
|
277
|
+
"HARD dynamic refs: executable playwright-cli lines must never contain an angle-bracket token such as <fresh-ref> or descriptive <...> placeholder. Use only shell-safe documentation placeholders `eX`, `eY`, ...; each means the real `eNN` ref parsed from the immediately preceding latest `snapshot`. Write a fresh snapshot before every element reference. eX/eY are never literal structured-tool arguments; a later snapshot invalidates prior refs, so never reuse stale refs.",
|
|
278
|
+
"HARD file-output argv: use canonical `--filename` only. Screenshot uses `playwright-cli screenshot --filename final.png` (a real ref may precede the flag); PDF uses `playwright-cli pdf --filename final.pdf`; snapshot without filename is response-only and a snapshot file uses `playwright-cli snapshot --filename snapshot.txt`. Never generate `playwright-cli screenshot <path>`, use `--path`, `--output`, or `--file`, or pass an output path as a positional target.",
|
|
279
|
+
"Each case must be independently reproducible with fixture/reset, UI reset, snapshot-before-ref, evidence write point under testcase/frontend/evidence/<case-id>/. If the isolated environment is unavailable, require writing blocked evidence before any browser command.",
|
|
280
|
+
"U/D data ownership (required for modify/delete): (1) Only mutate data whose ownership is proven by current login identity + observable UI/API owner fields - never by name/guessed id/list order alone. (2) If current user has no data, create tagged cleanable data in current-user context, then U/D, then cleanup+verify. (3) Else only task-authorized Mock, labeled as Mock (not real backend proof). (4) If ownership unverifiable and create/Mock unavailable: write blocked with blockedReason current-user-data-unavailable | data-ownership-unverifiable | safe-test-data-setup-unavailable - do not risk cross-user data. (5) Never touch other users, shared fixtures, production, or non-cleanable data.",
|
|
281
|
+
].join("\n\n"),
|
|
282
|
+
},
|
|
283
|
+
];
|
|
284
|
+
if (blockingReview) {
|
|
285
|
+
tasks.push({
|
|
286
|
+
id: "review-frontend-cases-pi",
|
|
287
|
+
depends_on: ["generate-frontend-functional-cases-pi"],
|
|
288
|
+
role: "reviewer",
|
|
289
|
+
executor: "pi",
|
|
290
|
+
complexity: "HIGH",
|
|
291
|
+
writePolicy: "read-only",
|
|
292
|
+
allowedPaths: [...ragWriteSet, ...casesWriteSet],
|
|
293
|
+
forbiddenPaths: forbidden,
|
|
294
|
+
outputContract: "First line VERDICT: pass or VERDICT: request-revision, followed by AC-to-case coverage and execution risk findings; no writes. request-revision blocks manifest materialization.",
|
|
295
|
+
subtask_prompt: "Review only the RAG package, frontend Markdown cases, and manifest.draft.json. Verify traceability, independent execution, safe data/environment handling, manifest correctness, session consistency, fixture/UI reset and fresh snapshot steps, and evidence requirements. Any Important or Critical finding requires VERDICT: request-revision. Browser execution is blocked unless this review passes.",
|
|
296
|
+
}, {
|
|
297
|
+
id: "revise-frontend-cases-pi",
|
|
298
|
+
depends_on: ["review-frontend-cases-pi"],
|
|
299
|
+
runIf: "$.nodes['review-frontend-cases-pi'].firstVerdictLine == 'VERDICT: request-revision'",
|
|
300
|
+
role: "implementer",
|
|
301
|
+
executor: "pi",
|
|
302
|
+
toolProfile: "write",
|
|
303
|
+
complexity: "HIGH",
|
|
304
|
+
writePolicy: "exclusive",
|
|
305
|
+
writeGuardPolicy: "tools-only",
|
|
306
|
+
writeSet: caseDraftWriteSet,
|
|
307
|
+
allowedPaths: [...ragWriteSet, ...caseDraftWriteSet],
|
|
308
|
+
forbiddenPaths: forbidden,
|
|
309
|
+
outputContract: "Apply the one permitted frontend case revision to FE-*.md, index.md, and manifest.draft.json only; the exclusive shell materializer remains the sole writer of manifest.json. No browser execution or evidence writes.",
|
|
310
|
+
subtask_prompt: "This is the only permitted case revision. Read the first review findings and the RAG package. Revise only testcase/frontend/cases/FE-*.md, testcase/frontend/cases/index.md, and testcase/frontend/cases/manifest.draft.json; preserve traceable AC mappings. Preserve the dynamic-ref contract: executable playwright-cli lines use only eX/eY-style shell-safe documentation placeholders, never <...>; each placeholder is resolved from the immediately preceding latest snapshot and stale refs are not reused. Do not write testcase/frontend/cases/manifest.json, execute a browser, or write evidence. HARD: Never delete case files; only edit in place or add missing cases. Preserve the full planned suite, index.md, and manifest.draft.json.",
|
|
311
|
+
}, {
|
|
312
|
+
id: "review-frontend-cases-final-pi",
|
|
313
|
+
depends_on: ["revise-frontend-cases-pi"],
|
|
314
|
+
runIf: "$.nodes['review-frontend-cases-pi'].firstVerdictLine == 'VERDICT: request-revision'",
|
|
315
|
+
role: "reviewer",
|
|
316
|
+
executor: "pi",
|
|
317
|
+
complexity: "HIGH",
|
|
318
|
+
writePolicy: "read-only",
|
|
319
|
+
allowedPaths: [...ragWriteSet, ...casesWriteSet],
|
|
320
|
+
forbiddenPaths: forbidden,
|
|
321
|
+
outputContract: "First line VERDICT: pass or VERDICT: request-revision after the single allowed case revision; no writes.",
|
|
322
|
+
subtask_prompt: "Perform the final frontend case review after the sole permitted revision. Apply the same traceability, isolation, manifest, reset, session, snapshot, and evidence checks. First verdict line must be exact; any Important or Critical finding requires request-revision. Do not write files.",
|
|
323
|
+
}, {
|
|
324
|
+
id: "final-frontend-case-review-gate-shell",
|
|
325
|
+
depends_on: [
|
|
326
|
+
"review-frontend-cases-pi",
|
|
327
|
+
"review-frontend-cases-final-pi",
|
|
328
|
+
],
|
|
329
|
+
dependsPolicy: "all-or-condition-skip",
|
|
330
|
+
role: "verifier",
|
|
331
|
+
executor: "shell",
|
|
332
|
+
complexity: "LOW",
|
|
333
|
+
writePolicy: "read-only",
|
|
334
|
+
allowedPaths: [...ragWriteSet, ...casesWriteSet],
|
|
335
|
+
forbiddenPaths: forbidden,
|
|
336
|
+
outputContract: "Pass-only effective frontend case review gate; final review takes precedence when the revision branch ran.",
|
|
337
|
+
subtask_prompt: "Authorize checklist/manifest materialization only after the effective frontend case review passes.",
|
|
338
|
+
shell: {
|
|
339
|
+
commands: [],
|
|
340
|
+
verdictGate: {
|
|
341
|
+
fromNodeId: "review-frontend-cases-final-pi",
|
|
342
|
+
fallbackFromNodeIds: ["review-frontend-cases-pi"],
|
|
343
|
+
accept: ["VERDICT: pass"],
|
|
344
|
+
label: "effective frontend case review",
|
|
345
|
+
lineMode: "first-verdict-line",
|
|
346
|
+
},
|
|
347
|
+
cwd: ".",
|
|
348
|
+
timeoutMs: 60000,
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const checklistDependsOn = blockingReview
|
|
353
|
+
? ["final-frontend-case-review-gate-shell"]
|
|
354
|
+
: ["generate-frontend-functional-cases-pi"];
|
|
355
|
+
tasks.push({
|
|
356
|
+
id: "checklist-and-materialize-manifest-shell",
|
|
357
|
+
depends_on: checklistDependsOn,
|
|
358
|
+
role: "verifier",
|
|
359
|
+
executor: "shell",
|
|
360
|
+
complexity: "LOW",
|
|
361
|
+
writePolicy: "exclusive",
|
|
362
|
+
writeSet: casesWriteSet,
|
|
363
|
+
allowedPaths: [...ragWriteSet, ...casesWriteSet],
|
|
364
|
+
forbiddenPaths: forbidden,
|
|
365
|
+
outputContract: "Mechanical checklist then atomic manifest.json materialization; stdout one final JSON line {cases}.",
|
|
366
|
+
subtask_prompt: "Run deterministic checklist then materialize manifest.json from draft after optional blocking review.",
|
|
367
|
+
shell: {
|
|
368
|
+
commands: [],
|
|
369
|
+
frontendTestCaseManifest: {
|
|
370
|
+
maxCases: config.maxCasesPerBatch,
|
|
371
|
+
},
|
|
372
|
+
cwd: ".",
|
|
373
|
+
timeoutMs: 120000,
|
|
374
|
+
},
|
|
375
|
+
}, {
|
|
376
|
+
id: "execute-frontend-cases-map",
|
|
377
|
+
depends_on: ["checklist-and-materialize-manifest-shell"],
|
|
378
|
+
role: "verifier",
|
|
379
|
+
executor: "static",
|
|
380
|
+
complexity: "LOW",
|
|
381
|
+
writePolicy: "none",
|
|
382
|
+
allowedPaths: [],
|
|
383
|
+
forbiddenPaths: forbidden,
|
|
384
|
+
outputContract: "Serial aggregate of case execution summaries, evidence paths, tokens, and token-budget or executor blocked/failed cases.",
|
|
385
|
+
subtask_prompt: "Expand and execute the validated frontend case manifest serially. Child executor failures become case-level failed/blocked evidence so closeout can still run.",
|
|
386
|
+
static: { resultMarkdown: "Frontend case map expansion barrier." },
|
|
387
|
+
dynamicExpansion: {
|
|
388
|
+
type: "map_agent",
|
|
389
|
+
workflowNodeId: "execute-frontend-cases-map",
|
|
390
|
+
itemsFrom: "$.nodes['checklist-and-materialize-manifest-shell'].output.cases",
|
|
391
|
+
itemName: "case",
|
|
392
|
+
maxItems: config.maxCasesPerBatch,
|
|
393
|
+
maxExpandedNodes: config.maxCasesPerBatch,
|
|
394
|
+
childIdPrefix: "execute-frontend-case",
|
|
395
|
+
workspaceTemplate: "{{case.evidenceDir}}",
|
|
396
|
+
tolerateChildFailures: true,
|
|
397
|
+
tokenBudget: {
|
|
398
|
+
maxTokensPerCase: config.maxTokensPerCase,
|
|
399
|
+
maxTotalTokens: config.maxTotalTokens,
|
|
400
|
+
},
|
|
401
|
+
childTask: {
|
|
402
|
+
executor: "pi",
|
|
403
|
+
role: "implementer",
|
|
404
|
+
skills: ["playwright-cli"],
|
|
405
|
+
toolProfile: "write",
|
|
406
|
+
commandPolicy: {
|
|
407
|
+
mode: "capability-allowlist",
|
|
408
|
+
capabilities: ["playwright-cli"],
|
|
409
|
+
},
|
|
410
|
+
complexity: "MED",
|
|
411
|
+
writePolicy: "exclusive",
|
|
412
|
+
writeGuardPolicy: "tools-only",
|
|
413
|
+
allowedPaths: [
|
|
414
|
+
"testcase/frontend/cases/{{case.caseId}}.md",
|
|
415
|
+
"testcase/frontend/rag/context.md",
|
|
416
|
+
"testcase/frontend/rag/coverage-map.md",
|
|
417
|
+
`${evidenceRoot}/{{case.caseId}}/**`,
|
|
418
|
+
],
|
|
419
|
+
forbiddenPaths: forbidden,
|
|
420
|
+
writeSet: [`${evidenceRoot}/{{case.caseId}}/**`],
|
|
421
|
+
outputContract: "Compact JSON <=1200 characters with case status, evidence paths, error summary, and tokens. Browser actions must use structured playwright_cli tool. Passed authority requires same-child ordered controller receipts: successful open → successful find → successful post-execution cleanup.",
|
|
422
|
+
subtaskPromptTemplate: [
|
|
423
|
+
"Primary job: EXECUTE case {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session; do not use /new). playwright-cli-only: never bare Playwright CLI/API/test runner; no fallback.",
|
|
424
|
+
"Use the structured playwright_cli tool for every browser action. Do not request or search for bash. Do not execute raw shell commands. Translate each playwright-cli line in the case Markdown into one playwright_cli tool call ({command, args?, timeoutSeconds?}).",
|
|
425
|
+
"1) Read the concrete baseUrl from testcase/frontend/rag/context.md; it has already passed the environment shell safety/reachability gate. Start browser ONLY via playwright_cli command=open with args [--browser=chrome, <that-concrete-baseUrl>] (default session only; no -s=). 2) Dynamic refs: eX/eY in case Markdown are documentation placeholders, never tool args. Immediately before every structured playwright_cli call that references an element, parse the current real eNN from the immediately preceding latest snapshot and pass only that real eNN; never send literal `eX`/`eY`. A new snapshot invalidates prior refs, so never reuse stale refs. File outputs are canonical: screenshot args [--filename, final.png] (or [e5, --filename, final.png] for a real target), PDF args [--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 any output path as a positional target. Follow case steps with snapshot before element refs using only playwright_cli. A passed case requires this same child receipt order: successful open → successful find → controller post-execution cleanup. Only successful find is a meaningful assertion; snapshot, goto, screenshot, request/console, click/fill and other ordinary interactions cannot establish passed authority. 3) Only when preflight or playwright_cli tool explicitly fails may you write blocked evidence (blockedReason playwright-cli-unavailable | frontend-base-url-unreachable); never invent CLI-unavailable solely because bash is absent. Unique-control early stop: when an AC names a specific control that is absent from the first post-navigation snapshot after reaching the required state (and no setup-required gate appeared), do ONE find with the case's literal as evidence; if it returns 0 matches, record status=blocked with blockedReason unique-control-unavailable citing that single find - do NOT re-explore with alternative literals, repeated snapshots, navigation detours, or rerun loops. 4) For U/D: enforce current-user ownership / create-or-mock-or-blocked; never mutate other users' data.",
|
|
426
|
+
"Always write {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json with caseId={{case.caseId}}, status passed|failed|blocked, evidencePaths (relative under evidenceDir). blocked needs non-empty blockedReason. After writing, self-check the same contract; if self-check fails, rewrite both files as status=blocked blockedReason=invalid-evidence-shape (never leave missing/malformed evidence).",
|
|
427
|
+
"Business failed/blocked is a recorded result, not a node failure. Close browser via playwright_cli command=close. Return compact JSON (<=1200 chars): {caseId,status,evidencePaths,errorSummary,tokens}.",
|
|
428
|
+
].join("\n\n"),
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
let rerunDependency = "execute-frontend-cases-map";
|
|
433
|
+
for (let round = 1; round <= maxRerunAttempts; round += 1) {
|
|
434
|
+
const selectorId = round === 1
|
|
435
|
+
? "select-frontend-rerun-candidates-shell"
|
|
436
|
+
: `select-frontend-rerun-candidates-round${round}-shell`;
|
|
437
|
+
const mapId = round === 1
|
|
438
|
+
? "rerun-frontend-cases-map"
|
|
439
|
+
: `rerun-frontend-cases-round${round}-map`;
|
|
440
|
+
const candidateArtifact = round === 1
|
|
441
|
+
? "rerun-candidates.json"
|
|
442
|
+
: `rerun-candidates-round${round}.json`;
|
|
443
|
+
const selectCommand = [
|
|
444
|
+
"const fs=require('fs'),path=require('path');",
|
|
445
|
+
"const manifestPath='testcase/frontend/cases/manifest.json';",
|
|
446
|
+
"if(!fs.existsSync(manifestPath)){process.stdout.write(JSON.stringify({cases:[]}));process.exit(0);}",
|
|
447
|
+
"const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));const cases=[];",
|
|
448
|
+
"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==='failed')reason='failed-retry';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'||reason==='failed-retry'||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'});}}",
|
|
449
|
+
`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}));`,
|
|
450
|
+
].join("");
|
|
451
|
+
tasks.push({
|
|
452
|
+
id: selectorId,
|
|
453
|
+
depends_on: [rerunDependency],
|
|
454
|
+
role: "verifier",
|
|
455
|
+
executor: "shell",
|
|
456
|
+
complexity: "LOW",
|
|
457
|
+
writePolicy: "exclusive",
|
|
458
|
+
writeSet: ["testcase/frontend/evidence/**"],
|
|
459
|
+
allowedPaths: [...casesWriteSet, "testcase/frontend/evidence/**"],
|
|
460
|
+
forbiddenPaths: forbidden,
|
|
461
|
+
outputContract: `Stdout JSON {cases} for blocked or missing-result-file cases eligible for rerun round ${round}/${maxRerunAttempts}.`,
|
|
462
|
+
subtask_prompt: "Select frontend-test cases eligible for this bounded rerun round.",
|
|
463
|
+
shell: {
|
|
464
|
+
commands: [["node -e", JSON.stringify(selectCommand)].join(" ")],
|
|
465
|
+
cwd: ".",
|
|
466
|
+
timeoutMs: 120_000,
|
|
467
|
+
},
|
|
468
|
+
}, {
|
|
469
|
+
id: mapId,
|
|
470
|
+
depends_on: [selectorId],
|
|
471
|
+
role: "verifier",
|
|
472
|
+
executor: "static",
|
|
473
|
+
complexity: "LOW",
|
|
474
|
+
writePolicy: "none",
|
|
475
|
+
allowedPaths: [],
|
|
476
|
+
forbiddenPaths: forbidden,
|
|
477
|
+
outputContract: `Serial rerun round ${round}/${maxRerunAttempts} of blocked or missing-result frontend cases.`,
|
|
478
|
+
subtask_prompt: "Expand rerun candidates into serial browser case children.",
|
|
479
|
+
static: { resultMarkdown: `Frontend case rerun round ${round} map expansion barrier.` },
|
|
480
|
+
dynamicExpansion: {
|
|
481
|
+
type: "map_agent",
|
|
482
|
+
workflowNodeId: mapId,
|
|
483
|
+
itemsFrom: `$.nodes['${selectorId}'].output.cases`,
|
|
484
|
+
itemName: "case",
|
|
485
|
+
maxItems: config.maxCasesPerBatch,
|
|
486
|
+
maxExpandedNodes: config.maxCasesPerBatch,
|
|
487
|
+
childIdPrefix: `rerun-frontend-case-r${round}`,
|
|
488
|
+
workspaceTemplate: "{{case.evidenceDir}}",
|
|
489
|
+
tolerateChildFailures: true,
|
|
490
|
+
tokenBudget: {
|
|
491
|
+
maxTokensPerCase: config.maxTokensPerCase,
|
|
492
|
+
maxTotalTokens: config.maxTotalTokens,
|
|
493
|
+
},
|
|
494
|
+
childTask: {
|
|
495
|
+
executor: "pi",
|
|
496
|
+
role: "implementer",
|
|
497
|
+
skills: ["playwright-cli"],
|
|
498
|
+
toolProfile: "write",
|
|
499
|
+
commandPolicy: { mode: "capability-allowlist", capabilities: ["playwright-cli"] },
|
|
500
|
+
complexity: "MED",
|
|
501
|
+
writePolicy: "exclusive",
|
|
502
|
+
writeGuardPolicy: "tools-only",
|
|
503
|
+
allowedPaths: [
|
|
504
|
+
"testcase/frontend/cases/{{case.caseId}}.md",
|
|
505
|
+
"testcase/frontend/rag/context.md",
|
|
506
|
+
"testcase/frontend/rag/coverage-map.md",
|
|
507
|
+
`${evidenceRoot}/{{case.caseId}}/**`,
|
|
508
|
+
],
|
|
509
|
+
forbiddenPaths: forbidden,
|
|
510
|
+
writeSet: [`${evidenceRoot}/{{case.caseId}}/**`],
|
|
511
|
+
outputContract: "Compact JSON <=1200 characters with final case status, evidence paths, error summary, tokens, and rerunAttempt.",
|
|
512
|
+
subtaskPromptTemplate: [
|
|
513
|
+
"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.",
|
|
514
|
+
"Primary job: EXECUTE case {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session). Use structured playwright_cli only; headless open.",
|
|
515
|
+
"Read the concrete baseUrl from testcase/frontend/rag/context.md and start via playwright_cli command=open with args [--browser=chrome, <that-concrete-baseUrl>]. Passed requires open → find → cleanup receipts.",
|
|
516
|
+
"Unique-control early stop: if the first post-navigation snapshot lacks the control an AC names (and no setup-required gate appeared), one find returning 0 matches is sufficient evidence - write status=blocked blockedReason unique-control-unavailable instead of exploratory retries.",
|
|
517
|
+
"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.",
|
|
518
|
+
].join("\n\n"),
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
rerunDependency = mapId;
|
|
523
|
+
}
|
|
524
|
+
tasks.push({
|
|
525
|
+
id: "finalize-frontend-test-result-shell",
|
|
526
|
+
depends_on: [
|
|
527
|
+
rerunDependency,
|
|
528
|
+
],
|
|
529
|
+
role: "verifier",
|
|
530
|
+
executor: "shell",
|
|
531
|
+
complexity: "LOW",
|
|
532
|
+
writePolicy: "exclusive",
|
|
533
|
+
writeSet: [`${evidenceRoot}/**`],
|
|
534
|
+
allowedPaths: ["testcase/frontend/cases/**", `${evidenceRoot}/**`],
|
|
535
|
+
forbiddenPaths: forbidden,
|
|
536
|
+
outputContract: "Evidence validation (advisory missing/malformed does not fail the node; hard-fail only path escape) then hash-bound frontend-test-result-v1. Node success means result-v1 was written, not that all cases passed.",
|
|
537
|
+
subtask_prompt: "Validate case evidence then materialize authoritative frontend-test-result-v1. Missing/malformed case evidence is advisory; only unsafe evidence paths hard-fail. Do not use Pi prose as input.",
|
|
538
|
+
shell: {
|
|
539
|
+
commands: [],
|
|
540
|
+
frontendTestResultFinalize: {},
|
|
541
|
+
cwd: ".",
|
|
542
|
+
timeoutMs: 120000,
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
tasks.push({
|
|
546
|
+
id: "frontend-test-reports-shell",
|
|
547
|
+
depends_on: ["finalize-frontend-test-result-shell"],
|
|
548
|
+
role: "verifier",
|
|
549
|
+
executor: "shell",
|
|
550
|
+
complexity: "LOW",
|
|
551
|
+
writePolicy: "exclusive",
|
|
552
|
+
writeSet: ["testcase/frontend/reports/**"],
|
|
553
|
+
allowedPaths: ["testcase/frontend/**"],
|
|
554
|
+
forbiddenPaths: forbidden,
|
|
555
|
+
outputContract: "Deterministic L-5 (optional) plus main frontend-test-report.md/html from frontend-test-result-v1. Pipeline acceptance = result-v1 + main HTML.",
|
|
556
|
+
subtask_prompt: "Render operational reports from frontend-test-result-v1 only. Do not invent coverage. Main HTML is required; L-5 follows frontendTest.reports.l5.",
|
|
557
|
+
shell: {
|
|
558
|
+
commands: [],
|
|
559
|
+
frontendTestReports: { l5: enableL5Report },
|
|
560
|
+
cwd: ".",
|
|
561
|
+
timeoutMs: 120000,
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
if (strictOutcomeGate) {
|
|
565
|
+
tasks.push({
|
|
566
|
+
id: "frontend-test-result-outcome-gate-shell",
|
|
567
|
+
depends_on: [
|
|
568
|
+
"finalize-frontend-test-result-shell",
|
|
569
|
+
"frontend-test-reports-shell",
|
|
570
|
+
],
|
|
571
|
+
role: "verifier",
|
|
572
|
+
executor: "shell",
|
|
573
|
+
complexity: "LOW",
|
|
574
|
+
writePolicy: "read-only",
|
|
575
|
+
allowedPaths: [],
|
|
576
|
+
forbiddenPaths: forbidden,
|
|
577
|
+
outputContract: "Optional quality gate: pass only when frontend-test-result-v1 is outcome=passed and integrationMode=real with 0 failed/blocked and no missing AC. Does not gate reports closeout.",
|
|
578
|
+
subtask_prompt: "Opt-in Delivery/Worker quality gate (frontendTest.strictOutcomeGate=true). Main reports do not depend on this node.",
|
|
579
|
+
shell: {
|
|
580
|
+
commands: [frontendTestOutcomeGate],
|
|
581
|
+
cwd: ".",
|
|
582
|
+
timeoutMs: 60000,
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
if (enableRetrospect) {
|
|
587
|
+
tasks.push({
|
|
588
|
+
id: "frontend-test-retrospect-pi",
|
|
589
|
+
depends_on: [
|
|
590
|
+
"finalize-frontend-test-result-shell",
|
|
591
|
+
"frontend-test-reports-shell",
|
|
592
|
+
],
|
|
593
|
+
role: "closeout",
|
|
594
|
+
executor: "pi",
|
|
595
|
+
toolProfile: "write",
|
|
596
|
+
complexity: "MED",
|
|
597
|
+
writePolicy: "exclusive",
|
|
598
|
+
writeGuardPolicy: "tools-only",
|
|
599
|
+
writeSet: ["testcase/frontend/reports/**"],
|
|
600
|
+
allowedPaths: ["testcase/frontend/**"],
|
|
601
|
+
forbiddenPaths: forbidden,
|
|
602
|
+
outputContract: "Optional retrospective under testcase/frontend/reports/frontend-test-retrospect-<date>.md. Not required for pipeline acceptance.",
|
|
603
|
+
subtask_prompt: "Write optional frontend-test retrospective after result + reports. Do not recompute L-5. Pipeline success does not require this file.",
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
const globalConstraints = [
|
|
607
|
+
...sources.taskConfig.hardConstraints,
|
|
608
|
+
...STANDARD_GLOBAL_CONSTRAINTS,
|
|
609
|
+
"frontend-test-dag generates Markdown cases and browser evidence only; it must not generate pytest or Playwright test source code.",
|
|
610
|
+
"Each browser case runs serially in a fresh Pi execution boundary. Persist its evidence before starting the next case.",
|
|
611
|
+
"Use only the declared isolated test environment. Production URLs, real credentials, and unauthorized data are blocked.",
|
|
612
|
+
"Browser startup for generated cases must use the concrete non-production baseUrl written by retrieve-frontend-test-context-pi and validated reachable by materialize-frontend-test-execution-shell; generated operations stay in the default browser session and must not use unverified named-session flags.",
|
|
613
|
+
"Browser-tool preflight runs before any frontend-test Pi node; cli-only rollback, missing/incompatible Pi SDK custom-tool capability, missing verified playwright-cli launcher, or incompatible --help fails with zero Pi calls.",
|
|
614
|
+
"Browser case children use commandPolicy capability-allowlist playwright-cli and structured playwright_cli tool; playwright-cli stays capability-gated while ordinary writers have bash.",
|
|
615
|
+
"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 are insufficient; missing or unordered receipts convert to blocked (browser-command-evidence-missing).",
|
|
616
|
+
"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.",
|
|
617
|
+
"Environment preflight must parse the retrieve-authored context.md baseUrl, reject unsafe/non-production violations, and curl-probe it before generate; unreachable or curl-unavailable ends preflight as blocked (frontend-base-url-unreachable|curl-unavailable) so generate/map do not run.",
|
|
618
|
+
"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.",
|
|
619
|
+
"Token settings are post-case stop thresholds, never a hard provider token cap. Unstarted cases after a threshold are blocked: token-budget-exhausted.",
|
|
620
|
+
"Pipeline acceptance for frontend-test is frontend-test-result-v1 plus the main report under testcase/frontend/reports/frontend-test-report.html; case pass rate and outcome=passed are quality signals; retrospect is opt-in (frontendTest.reports.retrospect).",
|
|
621
|
+
blockingReview
|
|
622
|
+
? "frontendTest.reviewMode=blocking: a frontend case review must emit VERDICT: pass before checklist/manifest materialization; request-revision blocks browser execution."
|
|
623
|
+
: "frontendTest.reviewMode is off|advisory by default: mechanical checklist-shell gates materialize/execute; LLM review is not a hard browser gate.",
|
|
624
|
+
];
|
|
625
|
+
const spec = {
|
|
626
|
+
version: 3,
|
|
627
|
+
title: `Frontend test DAG: ${sources.taskConfig.title}`,
|
|
628
|
+
runtimeContract: GENERATED_DAG_RUNTIME_CONTRACT,
|
|
629
|
+
outputLanguage: sources.outputLanguage ?? DEFAULT_DAG_OUTPUT_LANGUAGE,
|
|
630
|
+
objective: extractObjective(sources.requirementMarkdown, sources.taskConfig.title),
|
|
631
|
+
successCriteria: extractSuccessCriteria(sources.requirementMarkdown, sources.taskId),
|
|
632
|
+
globalConstraints,
|
|
633
|
+
defaults: {
|
|
634
|
+
...HYBRID_DEFAULTS,
|
|
635
|
+
skills: [],
|
|
636
|
+
writePolicy: "read-only",
|
|
637
|
+
// slim: large skill bodies + full source context cause Windows spawn ENAMETOOLONG
|
|
638
|
+
contextProfile: "slim",
|
|
639
|
+
},
|
|
640
|
+
skillsByRole: {
|
|
641
|
+
planner: [],
|
|
642
|
+
scout: ["playwright-cli"],
|
|
643
|
+
implementer: ["playwright-cli"],
|
|
644
|
+
reviewer: [],
|
|
645
|
+
verifier: ["playwright-cli"],
|
|
646
|
+
closeout: ["verification-before-completion"],
|
|
647
|
+
},
|
|
648
|
+
executorModels: sources.executorModelMatrix ?? DEFAULT_DAG_EXECUTOR_MODELS,
|
|
649
|
+
verifyStrategy: resolveDagVerifyStrategy(sources.taskConfig),
|
|
650
|
+
tasks,
|
|
651
|
+
};
|
|
652
|
+
applyFrontendTestLayoutToDagSpec(spec, layout);
|
|
653
|
+
applyDefaultReadOnlyRetryPolicy(spec);
|
|
654
|
+
stampGeneratedArtifactBindings(spec);
|
|
655
|
+
parseDagSpec(spec);
|
|
656
|
+
assertValidDagSpec(spec);
|
|
657
|
+
return spec;
|
|
658
|
+
}
|
|
659
|
+
export function applyFrontendTestLayoutToDagSpec(spec, layout) {
|
|
660
|
+
if (layout.isDefault) {
|
|
661
|
+
spec.frontendTestLayout = layout;
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const rewrite = (value) => applyFrontendTestLayoutToText(value, layout);
|
|
665
|
+
for (const task of spec.tasks) {
|
|
666
|
+
task.subtask_prompt = rewrite(task.subtask_prompt);
|
|
667
|
+
if (task.outputContract)
|
|
668
|
+
task.outputContract = rewrite(task.outputContract);
|
|
669
|
+
task.writeSet = task.writeSet?.map(rewrite);
|
|
670
|
+
task.allowedPaths = task.allowedPaths?.map(rewrite);
|
|
671
|
+
task.forbiddenPaths = task.forbiddenPaths?.map(rewrite);
|
|
672
|
+
if (task.dynamicExpansion?.childTask) {
|
|
673
|
+
const child = task.dynamicExpansion.childTask;
|
|
674
|
+
if (typeof child.subtaskPromptTemplate === "string") {
|
|
675
|
+
child.subtaskPromptTemplate = rewrite(child.subtaskPromptTemplate);
|
|
676
|
+
}
|
|
677
|
+
if (typeof child.outputContract === "string") {
|
|
678
|
+
child.outputContract = rewrite(child.outputContract);
|
|
679
|
+
}
|
|
680
|
+
if (Array.isArray(child.allowedPaths)) {
|
|
681
|
+
child.allowedPaths = child.allowedPaths.map((entry) => typeof entry === "string" ? rewrite(entry) : entry);
|
|
682
|
+
}
|
|
683
|
+
if (Array.isArray(child.writeSet)) {
|
|
684
|
+
child.writeSet = child.writeSet.map((entry) => typeof entry === "string" ? rewrite(entry) : entry);
|
|
685
|
+
}
|
|
686
|
+
if (Array.isArray(child.forbiddenPaths)) {
|
|
687
|
+
child.forbiddenPaths = child.forbiddenPaths.map((entry) => typeof entry === "string" ? rewrite(entry) : entry);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (task.shell?.commands) {
|
|
691
|
+
task.shell.commands = task.shell.commands.map(rewrite);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
spec.globalConstraints = spec.globalConstraints?.map(rewrite);
|
|
695
|
+
spec.successCriteria = spec.successCriteria?.map(rewrite);
|
|
696
|
+
spec.frontendTestLayout = layout;
|
|
697
|
+
}
|
|
698
|
+
export function allowedPathsCoverFrontendTestRoot(allowedPaths, testRoot) {
|
|
699
|
+
const probe = `${testRoot}/_layout_probe_`;
|
|
700
|
+
return allowedPaths.some((pattern) => pathMatchesPattern(testRoot, pattern) ||
|
|
701
|
+
pathMatchesPattern(probe, pattern));
|
|
702
|
+
}
|