@tea-agent/loop-agent 0.29.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/dist/commands/init.js +1 -1
- package/dist/executors/dag-pi-executor.js +3 -1
- package/dist/executors/pi-playwright-cli-tool.js +14 -8
- package/dist/executors/shell-executor.js +153 -0
- package/dist/task/config-types.js +21 -0
- package/dist/workflows/dag/frontend-test-case-checklist.js +94 -15
- package/dist/workflows/dag/frontend-test-case-manifest.js +104 -0
- package/dist/workflows/dag/frontend-test-html-report.js +106 -24
- package/dist/workflows/dag/frontend-test-result-contract.js +3 -0
- package/dist/workflows/dag/init-hybrid.js +167 -102
- package/dist/workflows/dag/types.js +40 -0
- package/dist/workflows/dag/validate.js +4 -1
- package/docs/templates/README.md +1 -0
- package/docs/templates/agent-dag.schema.json +6 -1
- package/docs/templates/frontend-test-case-checklist.md +1 -1
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +9 -1
- package/docs/templates/frontend-test-dag.json +125 -267
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -1
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/frontend-test-standard-scenarios.v1.json +114 -0
- package/package.json +1 -1
- package/skills/playwright-cli/SKILL.md +1 -1
- package/skills/playwright-cli-case-generator/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.29.1] - 2026-08-07
|
|
6
|
+
|
|
7
|
+
### 重点更新
|
|
8
|
+
|
|
9
|
+
- 前端测试默认执行流程大幅精简,收敛至约 11 个静态节点,合并了多项检查与报告步骤
|
|
10
|
+
- 前端测试浏览器默认改为无头(headless)模式,减少环境干扰
|
|
11
|
+
- 优化证据缺失处理逻辑,非路径逃逸的异常不再强制阻断报告生成
|
|
12
|
+
- 修复文档说明被误判为可执行命令的问题,提升执行稳定性
|
|
13
|
+
|
|
14
|
+
### 新增
|
|
15
|
+
|
|
16
|
+
- 新增前端测试标准场景物化节点,支持执行后针对阻塞或缺失证据进行最多两轮有界重跑
|
|
17
|
+
- 新增前端测试标准场景规范模板(frontend-test-standard-scenarios.v1.json)
|
|
18
|
+
- 前端测试独占节点支持 tools-only 写保护策略,在保留工具级写沙箱的同时跳过 Git 基线的硬失败限制
|
|
19
|
+
|
|
20
|
+
### 改进
|
|
21
|
+
|
|
22
|
+
- 前端测试默认 DAG 结构精简至约 11 个静态节点:合并双重重跑、检查清单与清单文件、证据与结果、L5 与 HTML 报告
|
|
23
|
+
- 前端测试主 HTML 报告不再依赖回顾(retrospect)节点,回顾功能改为可选开启
|
|
24
|
+
- 权威清单文件(manifest.json)仅记录规范化后的测试用例,并在未配置重试时省略相关静态节点
|
|
25
|
+
- 强化运营 HTML 报告的状态过滤功能,并优化失败与阻塞原因的展示
|
|
26
|
+
|
|
27
|
+
### 修复
|
|
28
|
+
|
|
29
|
+
- 修复前端测试检查清单将不可用或阻塞状态的说明文字误判为非法可执行命令的问题
|
|
30
|
+
- 修复前端测试收尾阶段因证据缺失或格式错误导致强制失败的问题,现仅对路径逃逸强制报错,其余降级为建议性提示
|
|
31
|
+
- 修复前端测试合并后清单文件物化异常的问题,确保仅写入规范化用例并恢复负向测试用例
|
|
32
|
+
- 回退误改的全局默认 Pi 模型(DEFAULT_PI_MODEL),将其恢复为 glm-5.2,模型路由统一交由 harness 复杂度矩阵处理
|
|
33
|
+
|
|
5
34
|
## [0.29.0] - 2026-08-07
|
|
6
35
|
|
|
7
36
|
### 重点更新
|
package/dist/commands/init.js
CHANGED
|
@@ -2217,7 +2217,7 @@ function buildTargetFeatureWorkflow(input) {
|
|
|
2217
2217
|
'- `taskKind: "backend-test"` selects the dedicated backend test DAG. Its Pi nodes analyze requirements, generate and review backend cases, generate pytest, and retrospect on results; shell gate/execution nodes enforce the review verdict and run the target project\'s pytest. The backend test templates (`backend-test-dag.json` and the `backend-test-dag.*.prompt.md` files) ship inside the loop-agent package as static references and are projected to target projects under the governance `templates/` directory.',
|
|
2218
2218
|
'- `taskKind: "knowledge-sync"` selects the Feature-scoped test-knowledge write-back DAG (collect → draft → validate → apply → pointer). Bind `featureId` in `task.json` (or hardConstraints / requirement text). It writes only under `features/<featureId>/…` after final verification evidence exists.',
|
|
2219
2219
|
'- `taskKind: "knowledge-graph-bootstrap"` selects the business knowledge-graph bootstrap DAG (preflight → inventory → propose → validate → review → gate → promote → materialize). AI writes only `knowledge/bootstrap/staging/**`; promote is merge-new-only.',
|
|
2220
|
-
'- `taskKind: "frontend-test"` selects the FE-test RAG DAG. It writes a traceable frontend RAG package and Markdown case manifest, then executes manifest cases serially with `playwright-cli` in isolated test environments and retains per-case evidence. It never generates pytest or Playwright source code. `frontendTest.maxCasesPerBatch` defaults to 20 (maximum 50); optional `maxTokensPerCase` and `maxTotalTokens` stop only later cases after a completed case\'s token usage is recorded, marking them `blocked: token-budget-exhausted`. Generated browser startup uses `playwright-cli open --browser=chrome --
|
|
2220
|
+
'- `taskKind: "frontend-test"` selects the FE-test RAG DAG. It writes a traceable frontend RAG package and Markdown case manifest, then executes manifest cases serially with `playwright-cli` in isolated test environments and retains per-case evidence. It never generates pytest or Playwright source code. `frontendTest.maxCasesPerBatch` defaults to 20 (maximum 50); optional `maxTokensPerCase` and `maxTotalTokens` stop only later cases after a completed case\'s token usage is recorded, marking them `blocked: token-budget-exhausted`. Generated browser startup uses `playwright-cli open --browser=chrome --headless <base-url>`; the generic playwright-cli skill is unchanged.',
|
|
2221
2221
|
"- Only eligible read-only Pi nodes (planner, scout, reviewer, verifier, closeout with no write-capable tool profile) receive the conservative automatic retry policy. Supervisor, implementer, writer, docs-only, dynamic, shell, static, and decision-gate nodes are not retried automatically. Eligible nodes cannot write repository files; the controller only records immutable attempt evidence under `.harness/dag-runs/<state>/<run-id>/<node-id>/attempt-<n>.json`.",
|
|
2222
2222
|
"",
|
|
2223
2223
|
"Use the package-backed public knowledge CLI for graph operations. Do not require target projects to run package-only kb runtime scripts:",
|
|
@@ -340,7 +340,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
340
340
|
}
|
|
341
341
|
let beforeStatus;
|
|
342
342
|
let beforePathFingerprints;
|
|
343
|
-
|
|
343
|
+
/** tools-only: keep SDK path sandbox; skip git baseline + post-diff write-guard hard fail. */
|
|
344
|
+
const skipGitWriteGuard = isWriteTask && input.task.writeGuardPolicy === "tools-only";
|
|
345
|
+
if (isWriteTask && !skipGitWriteGuard) {
|
|
344
346
|
try {
|
|
345
347
|
beforeStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-before" });
|
|
346
348
|
const beforeSnapshot = snapshotGitStatusPorcelain(beforeStatus);
|
|
@@ -197,15 +197,21 @@ function validateOpenArgs(args, baseUrl) {
|
|
|
197
197
|
for (const arg of args) {
|
|
198
198
|
assertNoControlMeta(arg);
|
|
199
199
|
assertNoSessionFlag(arg);
|
|
200
|
-
if (arg === "--browser=chrome" || arg === "--
|
|
200
|
+
if (arg === "--browser=chrome" || arg === "--headless") {
|
|
201
201
|
flags.add(arg);
|
|
202
202
|
normalized.push(arg);
|
|
203
203
|
continue;
|
|
204
204
|
}
|
|
205
|
+
// Accept legacy --headed but normalize to headless for frontend-test CI/dogfood.
|
|
206
|
+
if (arg === "--headed") {
|
|
207
|
+
flags.add("--headless");
|
|
208
|
+
if (!normalized.includes("--headless"))
|
|
209
|
+
normalized.push("--headless");
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
205
212
|
if (arg.startsWith("--browser=") ||
|
|
206
|
-
arg === "--browser"
|
|
207
|
-
|
|
208
|
-
throw new PlaywrightCliPolicyError("open-browser-flags", "open must use --browser=chrome --headed only");
|
|
213
|
+
arg === "--browser") {
|
|
214
|
+
throw new PlaywrightCliPolicyError("open-browser-flags", "open must use --browser=chrome --headless only");
|
|
209
215
|
}
|
|
210
216
|
if (/^[a-z][a-z0-9+.-]*:/i.test(arg) || arg.startsWith("http")) {
|
|
211
217
|
url = arg;
|
|
@@ -213,12 +219,12 @@ function validateOpenArgs(args, baseUrl) {
|
|
|
213
219
|
}
|
|
214
220
|
throw new PlaywrightCliPolicyError("open-args", `unsupported open argument: ${arg}`);
|
|
215
221
|
}
|
|
216
|
-
if (!flags.has("--browser=chrome") || !flags.has("--
|
|
222
|
+
if (!flags.has("--browser=chrome") || !flags.has("--headless")) {
|
|
217
223
|
// controller injects required flags when missing from model args
|
|
218
224
|
if (!flags.has("--browser=chrome"))
|
|
219
225
|
normalized.unshift("--browser=chrome");
|
|
220
|
-
if (!flags.has("--
|
|
221
|
-
normalized.push("--
|
|
226
|
+
if (!flags.has("--headless"))
|
|
227
|
+
normalized.push("--headless");
|
|
222
228
|
}
|
|
223
229
|
if (!url) {
|
|
224
230
|
throw new PlaywrightCliPolicyError("open-url-required", "open requires an absolute http(s) URL");
|
|
@@ -509,7 +515,7 @@ export function preparePlaywrightCliArgv(input, ctx) {
|
|
|
509
515
|
catch {
|
|
510
516
|
throw new PlaywrightCliPolicyError("goto-url-invalid", "goto requires a URL relative to the controller baseUrl");
|
|
511
517
|
}
|
|
512
|
-
const openLike = validateOpenArgs(["--browser=chrome", "--
|
|
518
|
+
const openLike = validateOpenArgs(["--browser=chrome", "--headless", target], ctx.baseUrl);
|
|
513
519
|
return openLike[openLike.length - 1];
|
|
514
520
|
});
|
|
515
521
|
if (args.length !== 1) {
|
|
@@ -18,6 +18,7 @@ import { materializeFrontendImplementationContract } from "../workflows/dag/fron
|
|
|
18
18
|
import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
|
|
19
19
|
import { renderFrontendTestL5Report } from "../workflows/dag/frontend-test-l5-report.js";
|
|
20
20
|
import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
|
|
21
|
+
import { materializeFrontendTestCaseManifest } from "../workflows/dag/frontend-test-case-manifest.js";
|
|
21
22
|
import { renderFrontendTestHtmlReport } from "../workflows/dag/frontend-test-html-report.js";
|
|
22
23
|
import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
|
|
23
24
|
import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
|
|
@@ -1826,6 +1827,149 @@ async function executeFrontendTestEvidenceValidation(input) {
|
|
|
1826
1827
|
};
|
|
1827
1828
|
}
|
|
1828
1829
|
}
|
|
1830
|
+
async function executeFrontendTestCaseManifest(input, meta) {
|
|
1831
|
+
const started = Date.now();
|
|
1832
|
+
try {
|
|
1833
|
+
const gate = input.task.shell?.frontendTestCaseManifest ?? {};
|
|
1834
|
+
const declaredAcIds = gate.declaredAcIds ??
|
|
1835
|
+
(meta.spec.sourceBinding?.requirementIds ?? []).filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
|
|
1836
|
+
const checklist = await validateFrontendCaseChecklist({
|
|
1837
|
+
workspaceRoot: input.cwd,
|
|
1838
|
+
declaredAcIds,
|
|
1839
|
+
});
|
|
1840
|
+
if (checklist.issues.length) {
|
|
1841
|
+
return {
|
|
1842
|
+
ok: false,
|
|
1843
|
+
stdout: "",
|
|
1844
|
+
stderr: `frontend-test checklist blocked: ${JSON.stringify(checklist.issues)}`,
|
|
1845
|
+
failureCategory: "invalid-output",
|
|
1846
|
+
durationMs: Date.now() - started,
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
const materialized = await materializeFrontendTestCaseManifest({
|
|
1850
|
+
workspaceRoot: input.cwd,
|
|
1851
|
+
maxCases: gate.maxCases,
|
|
1852
|
+
declaredAcIds,
|
|
1853
|
+
});
|
|
1854
|
+
return {
|
|
1855
|
+
ok: true,
|
|
1856
|
+
stdout: `${JSON.stringify({ cases: materialized.cases })}
|
|
1857
|
+
`,
|
|
1858
|
+
stderr: "",
|
|
1859
|
+
failureCategory: "success",
|
|
1860
|
+
durationMs: Date.now() - started,
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
catch (error) {
|
|
1864
|
+
return {
|
|
1865
|
+
ok: false,
|
|
1866
|
+
stdout: "",
|
|
1867
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
1868
|
+
failureCategory: "invalid-output",
|
|
1869
|
+
durationMs: Date.now() - started,
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
/** Path-escape / cross-case evidence errors must hard-fail finalize (same class as validate.hardFail). */
|
|
1874
|
+
function isFrontendTestEvidencePathHardError(error) {
|
|
1875
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1876
|
+
return /unsafe evidence|escapes (?:evidence root|its allowed root)|evidence realpath|cross-case/i.test(message);
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Lean finalize: evidence validate + result materialize in one shell.
|
|
1880
|
+
* - Case-level missing/malformed evidence is advisory (issues / advisoryFindings);
|
|
1881
|
+
* the node still succeeds so reports can render incomplete/failed outcomes.
|
|
1882
|
+
* - Path escape / unsafe evidence roots hard-fail the node.
|
|
1883
|
+
* - Controller/contract errors (missing sourceBinding, empty manifest) still fail closed.
|
|
1884
|
+
*/
|
|
1885
|
+
async function executeFrontendTestResultFinalize(input, meta) {
|
|
1886
|
+
const started = Date.now();
|
|
1887
|
+
let advisoryStdout = "";
|
|
1888
|
+
try {
|
|
1889
|
+
const evidence = await validateFrontendCaseEvidence({
|
|
1890
|
+
workspaceRoot: input.cwd,
|
|
1891
|
+
});
|
|
1892
|
+
advisoryStdout = `frontend case evidence validation cases=${evidence.cases} findings=${evidence.issues.length}${evidence.issues.length ? ` issues=${JSON.stringify(evidence.issues)}` : ""}`;
|
|
1893
|
+
// Advisory findings must not block materialize; only hardFail (path escape) does.
|
|
1894
|
+
if (evidence.hardFail) {
|
|
1895
|
+
return {
|
|
1896
|
+
ok: false,
|
|
1897
|
+
stdout: advisoryStdout,
|
|
1898
|
+
stderr: `frontend-test evidence hard-fail: ${JSON.stringify(evidence.issues)}`,
|
|
1899
|
+
failureCategory: "nonzero-exit",
|
|
1900
|
+
durationMs: Date.now() - started,
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
const artifact = await materializeFrontendTestResult({
|
|
1904
|
+
runDir: meta.runDir,
|
|
1905
|
+
workspaceRoot: input.cwd,
|
|
1906
|
+
artifactName: "frontend-test-result.json",
|
|
1907
|
+
outputDir: "contracts",
|
|
1908
|
+
sourceBinding: meta.spec.sourceBinding,
|
|
1909
|
+
});
|
|
1910
|
+
return {
|
|
1911
|
+
ok: true,
|
|
1912
|
+
stdout: `${advisoryStdout}\nStructured artifact: ${artifact.path}\nSchema: ${artifact.schemaId}\nSHA-256: ${artifact.sha256}`,
|
|
1913
|
+
stderr: "",
|
|
1914
|
+
failureCategory: "success",
|
|
1915
|
+
durationMs: Date.now() - started,
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
catch (error) {
|
|
1919
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1920
|
+
if (isFrontendTestEvidencePathHardError(error)) {
|
|
1921
|
+
return {
|
|
1922
|
+
ok: false,
|
|
1923
|
+
stdout: advisoryStdout,
|
|
1924
|
+
stderr: `frontend-test evidence hard-fail: ${detail}`,
|
|
1925
|
+
failureCategory: "nonzero-exit",
|
|
1926
|
+
durationMs: Date.now() - started,
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
1929
|
+
return {
|
|
1930
|
+
ok: false,
|
|
1931
|
+
stdout: advisoryStdout,
|
|
1932
|
+
stderr: detail,
|
|
1933
|
+
failureCategory: "invalid-output",
|
|
1934
|
+
durationMs: Date.now() - started,
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
async function executeFrontendTestReports(input, meta) {
|
|
1939
|
+
const started = Date.now();
|
|
1940
|
+
try {
|
|
1941
|
+
const writeL5 = input.task.shell?.frontendTestReports?.l5 !== false;
|
|
1942
|
+
const lines = [];
|
|
1943
|
+
if (writeL5) {
|
|
1944
|
+
const l5 = await renderFrontendTestL5Report({
|
|
1945
|
+
workspaceRoot: input.cwd,
|
|
1946
|
+
runDir: meta.runDir,
|
|
1947
|
+
});
|
|
1948
|
+
lines.push(`Frontend L-5 report: ${l5.htmlPath}\nMarkdown: ${l5.markdownPath}\nStatus: ${l5.metrics.status}`);
|
|
1949
|
+
}
|
|
1950
|
+
const report = await renderFrontendTestHtmlReport({
|
|
1951
|
+
workspaceRoot: input.cwd,
|
|
1952
|
+
runDir: meta.runDir,
|
|
1953
|
+
});
|
|
1954
|
+
lines.push(`Frontend test report: ${report.htmlPath}\nMarkdown: ${report.markdownPath}\nOutcome: ${report.outcome}\nCases: ${report.caseCount}`);
|
|
1955
|
+
return {
|
|
1956
|
+
ok: true,
|
|
1957
|
+
stdout: lines.join("\n"),
|
|
1958
|
+
stderr: "",
|
|
1959
|
+
failureCategory: "success",
|
|
1960
|
+
durationMs: Date.now() - started,
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
catch (error) {
|
|
1964
|
+
return {
|
|
1965
|
+
ok: false,
|
|
1966
|
+
stdout: "",
|
|
1967
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
1968
|
+
failureCategory: "invalid-output",
|
|
1969
|
+
durationMs: Date.now() - started,
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1829
1973
|
async function executeFrontendLintBaseline(input, meta) {
|
|
1830
1974
|
const started = Date.now();
|
|
1831
1975
|
const shell = input.task.shell;
|
|
@@ -2062,15 +2206,24 @@ export async function executeDagShellNode(input, meta) {
|
|
|
2062
2206
|
if (shell?.frontendTestCaseChecklist) {
|
|
2063
2207
|
return executeFrontendTestCaseChecklist(input, meta);
|
|
2064
2208
|
}
|
|
2209
|
+
if (shell?.frontendTestCaseManifest) {
|
|
2210
|
+
return executeFrontendTestCaseManifest(input, meta);
|
|
2211
|
+
}
|
|
2065
2212
|
if (shell?.frontendTestEvidenceValidation) {
|
|
2066
2213
|
return executeFrontendTestEvidenceValidation(input);
|
|
2067
2214
|
}
|
|
2215
|
+
if (shell?.frontendTestResultFinalize) {
|
|
2216
|
+
return executeFrontendTestResultFinalize(input, meta);
|
|
2217
|
+
}
|
|
2068
2218
|
if (shell?.frontendTestL5Report) {
|
|
2069
2219
|
return executeFrontendTestL5Report(input, meta);
|
|
2070
2220
|
}
|
|
2071
2221
|
if (shell?.frontendTestHtmlReport) {
|
|
2072
2222
|
return executeFrontendTestHtmlReport(input, meta);
|
|
2073
2223
|
}
|
|
2224
|
+
if (shell?.frontendTestReports) {
|
|
2225
|
+
return executeFrontendTestReports(input, meta);
|
|
2226
|
+
}
|
|
2074
2227
|
if (shell?.backendTestPipeline) {
|
|
2075
2228
|
return executeBackendTestPipelineWithWriteGuard(input, meta);
|
|
2076
2229
|
}
|
|
@@ -83,6 +83,27 @@ export const frontendTestConfigSchema = z.object({
|
|
|
83
83
|
* Default false: pipeline success is result materialize + retrospect report, not full green.
|
|
84
84
|
*/
|
|
85
85
|
strictOutcomeGate: z.boolean().optional(),
|
|
86
|
+
/**
|
|
87
|
+
* Maximum rerun attempts for blocked or missing-result frontend cases.
|
|
88
|
+
* Defaults to 2; the static graph always carries a single rerun select+map
|
|
89
|
+
* pair and bounds candidates within that pair via this value.
|
|
90
|
+
*/
|
|
91
|
+
maxRerunAttempts: z.number().int().min(0).max(4).optional(),
|
|
92
|
+
/**
|
|
93
|
+
* Report chain toggles for the lean frontend-test DAG.
|
|
94
|
+
* - retrospect: opt-in Pi retrospective node (default off). Pipeline success
|
|
95
|
+
* is frontend-test-result-v1 + main HTML/MD report, not the retrospect.
|
|
96
|
+
* - l5: write the deterministic L-5 dashboard from the merged reports node
|
|
97
|
+
* (default true; the merged reports node always renders the main
|
|
98
|
+
* HTML/MD report regardless of this flag).
|
|
99
|
+
*/
|
|
100
|
+
reports: z
|
|
101
|
+
.object({
|
|
102
|
+
retrospect: z.boolean().optional(),
|
|
103
|
+
l5: z.boolean().optional(),
|
|
104
|
+
})
|
|
105
|
+
.strict()
|
|
106
|
+
.optional(),
|
|
86
107
|
});
|
|
87
108
|
export const convergenceConfigSchema = z.object({
|
|
88
109
|
enabled: z.boolean().optional().default(false),
|
|
@@ -6,6 +6,9 @@ const LIST_ITEM = /^\s*(?:[-+*]|\d+[.)])\s+(.+)$/;
|
|
|
6
6
|
const INDENTED_CODE = /^(?: {4,}|\t+)(\S.*)$/;
|
|
7
7
|
const EXPLICIT_COMMAND = /^(?:(?:shell|terminal)(?:\s+command)?|command|run command|execute command)\s*:\s*(.+)$/i;
|
|
8
8
|
const BLOCKED_REASON_PREFIX = /^(?:(?:blocked|forbidden|reject(?:ed)?|disallow(?:ed)?|prohibited)(?:\s+reason)?\s*(?::|\bbecause\b)|(?:do not|must not|never)\s+(?:run|execute|use)\b)/i;
|
|
9
|
+
/** Standalone blockedReason / environment tokens (not executable commands). */
|
|
10
|
+
const BLOCKED_REASON_TOKEN = /^(?:playwright-cli-unavailable|frontend-base-url-unreachable|curl-unavailable|browser-command-capability-unavailable|browser-command-evidence-missing|token-budget-exhausted|current-user-data-unavailable|data-ownership-unverifiable|safe-test-data-setup-unavailable|invalid-evidence-shape)(?:\s|$|[.,;:)`'"\]])/i;
|
|
11
|
+
const BLOCKED_PROSE_HINT = /(?:blockedReason|blocked\s*reason|environmentProbe|evidenceDir|\bunavailable\b|不可用|写\s*blocked|写入\s*blocked|伪命令|自然语言|元数据|fail-closed|探测失败)/i;
|
|
9
12
|
const IMPERATIVE_COMMAND = /^(?:(?:run|execute|use)(?:\s+(?:the\s+)?(?:(?:shell|terminal)\s+)?command)?|in\s+(?:the\s+)?(?:shell|terminal|console)\s*,?\s*(?:run|execute|use))\s*:?\s+(.+)$/i;
|
|
10
13
|
const INLINE_CODE_STEP = /^`([^`\r\n]+)`[.!?]?$/;
|
|
11
14
|
const AUTOMATION_EXECUTABLE = /^(?:playwright-cli\b|playwright\b|@playwright\/test\b|npx\b|npm\b|pnpm\b|yarn\b|bunx?\b|node(?:js)?\b|python(?:3)?\b|bash\b|sh\b|zsh\b|fish\b|powershell\b|pwsh\b|cmd(?:\.exe)?\b|cypress\b|selenium\b|webdriverio\b|chromedriver\b|google-chrome\b|chrome\b|firefox\b|curl\b|wget\b)/i;
|
|
@@ -31,7 +34,53 @@ function isExecutableFence(language) {
|
|
|
31
34
|
return COMMAND_FENCE_LANGUAGE.test(language.trim());
|
|
32
35
|
}
|
|
33
36
|
function isBlockedReason(value) {
|
|
34
|
-
|
|
37
|
+
const trimmed = value.trim();
|
|
38
|
+
if (!trimmed)
|
|
39
|
+
return false;
|
|
40
|
+
if (BLOCKED_REASON_PREFIX.test(trimmed))
|
|
41
|
+
return true;
|
|
42
|
+
// Bare reason enum / metadata line (BugPilot blocked sections).
|
|
43
|
+
if (BLOCKED_REASON_TOKEN.test(trimmed.replace(/^`+|`+$/g, "")))
|
|
44
|
+
return true;
|
|
45
|
+
if (/^blockedReason\s*[:=]/i.test(trimmed))
|
|
46
|
+
return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Non-executable prose that mentions playwright-cli / reason tokens for blocked
|
|
51
|
+
* paths (e.g. "playwright-cli 不可用时… blockedReason: …"). Must not enter the
|
|
52
|
+
* command allowlist gate.
|
|
53
|
+
*/
|
|
54
|
+
function isNonExecutableBlockedProse(value) {
|
|
55
|
+
const trimmed = value.trim();
|
|
56
|
+
if (!trimmed)
|
|
57
|
+
return false;
|
|
58
|
+
if (isBlockedReason(trimmed))
|
|
59
|
+
return true;
|
|
60
|
+
const normalized = normalizeCommand(trimmed);
|
|
61
|
+
const bare = normalized.replace(/^`+|`+$/g, "").trim();
|
|
62
|
+
if (BLOCKED_REASON_TOKEN.test(bare))
|
|
63
|
+
return true;
|
|
64
|
+
// "playwright-cli 不可用…" / "playwright-cli unavailable…" documentation.
|
|
65
|
+
if (/^playwright-cli\b/i.test(normalized) &&
|
|
66
|
+
BLOCKED_PROSE_HINT.test(normalized) &&
|
|
67
|
+
!/^playwright-cli\s+[a-z][a-z0-9-]*\b/i.test(normalized)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
const pw = normalized.match(/^playwright-cli\s+(\S+)([\s\S]*)$/i);
|
|
71
|
+
if (pw) {
|
|
72
|
+
const first = pw[1].replace(/^[`'"]+|[`'":,,。→]+$/g, "");
|
|
73
|
+
if (!isPlaywrightCliCommand(first.toLowerCase())) {
|
|
74
|
+
// Non-verb after playwright-cli in prose (e.g. 不可用 / unavailable).
|
|
75
|
+
return /[\u4e00-\u9fff]/.test(first) || BLOCKED_PROSE_HINT.test(normalized) || !/^[a-z][a-z0-9-]*$/i.test(first);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Reason token embedded without looking like a real CLI invocation.
|
|
79
|
+
if (BLOCKED_PROSE_HINT.test(trimmed) &&
|
|
80
|
+
!/^playwright-cli\s+[a-z][a-z0-9-]*(\s+--|\s+https?:|\s*$)/i.test(normalized)) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
35
84
|
}
|
|
36
85
|
function stripLeadingCommandWrappers(command) {
|
|
37
86
|
let remaining = command.trim();
|
|
@@ -64,17 +113,30 @@ function isCommandLikeExecutable(command) {
|
|
|
64
113
|
}
|
|
65
114
|
function executableListStep(value) {
|
|
66
115
|
const trimmed = value.trim();
|
|
116
|
+
if (isNonExecutableBlockedProse(trimmed))
|
|
117
|
+
return null;
|
|
67
118
|
const explicit = trimmed.match(EXPLICIT_COMMAND);
|
|
68
|
-
if (explicit)
|
|
69
|
-
|
|
119
|
+
if (explicit) {
|
|
120
|
+
if (isBlockedReason(explicit[1]) || isNonExecutableBlockedProse(explicit[1]))
|
|
121
|
+
return null;
|
|
122
|
+
return normalizeCommand(explicit[1]);
|
|
123
|
+
}
|
|
70
124
|
const imperative = trimmed.match(IMPERATIVE_COMMAND);
|
|
71
|
-
if (imperative)
|
|
125
|
+
if (imperative) {
|
|
126
|
+
if (isNonExecutableBlockedProse(imperative[1]))
|
|
127
|
+
return null;
|
|
72
128
|
return normalizeCommand(imperative[1]);
|
|
129
|
+
}
|
|
73
130
|
const inlineCode = trimmed.match(INLINE_CODE_STEP);
|
|
74
|
-
if (inlineCode)
|
|
131
|
+
if (inlineCode) {
|
|
132
|
+
if (isNonExecutableBlockedProse(inlineCode[1]))
|
|
133
|
+
return null;
|
|
75
134
|
return normalizeCommand(inlineCode[1]);
|
|
135
|
+
}
|
|
76
136
|
const prompted = /^[$>]\s*\S/.test(trimmed);
|
|
77
137
|
const normalized = normalizeCommand(trimmed);
|
|
138
|
+
if (isNonExecutableBlockedProse(normalized))
|
|
139
|
+
return null;
|
|
78
140
|
if (prompted || isCommandLikeExecutable(normalized))
|
|
79
141
|
return normalized;
|
|
80
142
|
return null;
|
|
@@ -127,12 +189,15 @@ function extractExecutableInstructions(markdown) {
|
|
|
127
189
|
if (fenceLanguage !== null) {
|
|
128
190
|
if (!isExecutableFence(fenceLanguage) || /^(?:#|\/\/)/.test(trimmed))
|
|
129
191
|
continue;
|
|
130
|
-
|
|
192
|
+
// Fenced blocks are strict: only skip pure reason-token / blockedReason lines.
|
|
193
|
+
if (isBlockedReason(trimmed) || BLOCKED_REASON_TOKEN.test(normalizeCommand(trimmed).replace(/^`+|`+$/g, "")))
|
|
194
|
+
continue;
|
|
195
|
+
instructions.push({ command: normalizeCommand(trimmed), lineNumber, fromFence: true });
|
|
131
196
|
continue;
|
|
132
197
|
}
|
|
133
198
|
const listed = line.match(LIST_ITEM);
|
|
134
199
|
if (listed) {
|
|
135
|
-
if (isBlockedReason(listed[1]))
|
|
200
|
+
if (isBlockedReason(listed[1]) || isNonExecutableBlockedProse(listed[1]))
|
|
136
201
|
continue;
|
|
137
202
|
const command = executableListStep(listed[1]);
|
|
138
203
|
if (command !== null)
|
|
@@ -141,17 +206,20 @@ function extractExecutableInstructions(markdown) {
|
|
|
141
206
|
}
|
|
142
207
|
const indented = line.match(INDENTED_CODE);
|
|
143
208
|
if (indented) {
|
|
144
|
-
if (isBlockedReason(indented[1]))
|
|
209
|
+
if (isBlockedReason(indented[1]) || isNonExecutableBlockedProse(indented[1]))
|
|
145
210
|
continue;
|
|
146
211
|
const explicitIndented = indented[1].match(EXPLICIT_COMMAND);
|
|
147
|
-
if (explicitIndented && isBlockedReason(explicitIndented[1]))
|
|
212
|
+
if (explicitIndented && (isBlockedReason(explicitIndented[1]) || isNonExecutableBlockedProse(explicitIndented[1])))
|
|
213
|
+
continue;
|
|
214
|
+
const fromStep = executableListStep(indented[1]);
|
|
215
|
+
if (fromStep === null)
|
|
148
216
|
continue;
|
|
149
|
-
instructions.push({ command:
|
|
217
|
+
instructions.push({ command: fromStep, lineNumber });
|
|
150
218
|
continue;
|
|
151
219
|
}
|
|
152
220
|
const explicit = trimmed.match(EXPLICIT_COMMAND);
|
|
153
221
|
if (explicit) {
|
|
154
|
-
if (isBlockedReason(explicit[1]))
|
|
222
|
+
if (isBlockedReason(explicit[1]) || isNonExecutableBlockedProse(explicit[1]))
|
|
155
223
|
continue;
|
|
156
224
|
instructions.push({ command: normalizeCommand(explicit[1]), lineNumber });
|
|
157
225
|
continue;
|
|
@@ -163,9 +231,16 @@ function extractExecutableInstructions(markdown) {
|
|
|
163
231
|
return instructions;
|
|
164
232
|
}
|
|
165
233
|
function commandGateIssue(input) {
|
|
234
|
+
// Outside fences, blocked/unavailable prose must never become a gate failure.
|
|
235
|
+
if (!input.instruction.fromFence && isNonExecutableBlockedProse(input.instruction.command)) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
if (input.instruction.fromFence && isBlockedReason(input.instruction.command)) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
166
241
|
const commandMatch = input.instruction.command.match(/^playwright-cli\s+([^\s`]+)/i);
|
|
167
242
|
if (commandMatch) {
|
|
168
|
-
const command = commandMatch[1].toLowerCase();
|
|
243
|
+
const command = commandMatch[1].toLowerCase().replace(/^[`'"]+|[`'":,,。→]+$/g, "");
|
|
169
244
|
if (isPlaywrightCliCommand(command) && !hasShellControl(input.instruction.command))
|
|
170
245
|
return null;
|
|
171
246
|
if (isPlaywrightCliCommand(command)) {
|
|
@@ -178,6 +253,10 @@ function commandGateIssue(input) {
|
|
|
178
253
|
detail: "shell control or additional executable fragments are not allowed after playwright-cli commands",
|
|
179
254
|
};
|
|
180
255
|
}
|
|
256
|
+
// Fenced bad verbs stay rejected; unfenced non-verbs already filtered as prose.
|
|
257
|
+
if (!input.instruction.fromFence && isNonExecutableBlockedProse(input.instruction.command)) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
181
260
|
return {
|
|
182
261
|
ruleId: "playwright-cli-command-not-allowed",
|
|
183
262
|
caseId: input.caseId,
|
|
@@ -210,7 +289,7 @@ export async function validateFrontendCaseChecklist(input) {
|
|
|
210
289
|
const issues = [];
|
|
211
290
|
const caseIdRe = /^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;
|
|
212
291
|
const acIdRe = /^AC(?:-[A-Z0-9]+)+$/i;
|
|
213
|
-
const openRe = /playwright-cli\s+open\s+--browser=chrome\s+--
|
|
292
|
+
const openRe = /playwright-cli\s+open\s+--browser=chrome\s+--headless\s+https?:\/\/\S+/i;
|
|
214
293
|
const productionHostRe = /(^|[.-])(prod|production)([.-]|$)/i;
|
|
215
294
|
for (const raw of manifest.cases) {
|
|
216
295
|
const item = raw;
|
|
@@ -231,8 +310,8 @@ export async function validateFrontendCaseChecklist(input) {
|
|
|
231
310
|
issues.push({ ruleId: "case-path-mismatch", caseId: id, casePath, detail: `${casePath} must equal ${expectedPath}` });
|
|
232
311
|
const body = await readFile(absolute, "utf8");
|
|
233
312
|
if (!openRe.test(body))
|
|
234
|
-
issues.push({ ruleId: "open-prefix", caseId: id, casePath, detail: "missing playwright-cli open --browser=chrome --
|
|
235
|
-
const match = body.match(/playwright-cli\s+open\s+--browser=chrome\s+--
|
|
313
|
+
issues.push({ ruleId: "open-prefix", caseId: id, casePath, detail: "missing playwright-cli open --browser=chrome --headless <absolute-url>" });
|
|
314
|
+
const match = body.match(/playwright-cli\s+open\s+--browser=chrome\s+--headless\s+(https?:\/\/\S+)/i);
|
|
236
315
|
if (match) {
|
|
237
316
|
try {
|
|
238
317
|
const url = new URL(match[1].replace(/[)\]},.\"'`]+$/, ""));
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFile, rename, unlink, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const DIMENSIONS = new Set(["core", "boundary", "flow", "backend"]);
|
|
4
|
+
function fail(ruleId, detail) {
|
|
5
|
+
const msg = JSON.stringify({ ruleId, detail: String(detail ?? "") });
|
|
6
|
+
console.error(`frontend-test manifest blocked: ${msg}`);
|
|
7
|
+
throw new Error(`frontend-test manifest blocked: ${ruleId}${detail ? `: ${detail}` : ""}`);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Validate manifest.draft.json and atomically materialize
|
|
11
|
+
* testcase/frontend/cases/manifest.json via temp+rename, then delete the draft.
|
|
12
|
+
*
|
|
13
|
+
* Writes only a normalized { schemaVersion: 1, cases } payload — never the raw
|
|
14
|
+
* draft object — so generator typos in casePath/evidenceDir/extra fields cannot
|
|
15
|
+
* leak into the authoritative manifest.
|
|
16
|
+
*
|
|
17
|
+
* unknown-ac is enforced only when declaredAcIds is non-empty (sourceBinding
|
|
18
|
+
* present). When empty, ac-id-shape still applies.
|
|
19
|
+
*/
|
|
20
|
+
export async function materializeFrontendTestCaseManifest(input) {
|
|
21
|
+
const draft = path.join(input.workspaceRoot, "testcase/frontend/cases/manifest.draft.json");
|
|
22
|
+
const file = path.join(input.workspaceRoot, "testcase/frontend/cases/manifest.json");
|
|
23
|
+
const tmp = `${file}.tmp`;
|
|
24
|
+
const maxCases = input.maxCases ?? 32;
|
|
25
|
+
const declaredAc = new Set(input.declaredAcIds ?? []);
|
|
26
|
+
let manifest;
|
|
27
|
+
try {
|
|
28
|
+
manifest = JSON.parse(await readFile(draft, "utf8"));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
fail("draft-missing", `missing ${draft}`);
|
|
32
|
+
}
|
|
33
|
+
if (manifest.schemaVersion !== 1)
|
|
34
|
+
fail("draft-schema", "schemaVersion must be 1");
|
|
35
|
+
if (!Array.isArray(manifest.cases) || manifest.cases.length === 0)
|
|
36
|
+
fail("draft-empty-cases", "cases must be a non-empty array");
|
|
37
|
+
if (manifest.cases.length > maxCases)
|
|
38
|
+
fail("map-capacity-exceeded", `cases=${manifest.cases.length} exceeds maxCasesPerBatch/maxExpandedNodes=${maxCases}; raise frontendTest.maxCasesPerBatch or shrink the suite`);
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const seenCasePath = new Set();
|
|
41
|
+
const seenEvidenceDir = new Set();
|
|
42
|
+
const acIdRe = /^AC(?:-[A-Z0-9]+)+$/i;
|
|
43
|
+
const cases = [];
|
|
44
|
+
for (const raw of manifest.cases) {
|
|
45
|
+
const c = raw;
|
|
46
|
+
if (!c ||
|
|
47
|
+
typeof c.caseId !== "string" ||
|
|
48
|
+
!/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/.test(c.caseId))
|
|
49
|
+
fail("case-id-shape", `caseId must be FE-*, never AC-FE-*: ${String(c?.caseId)}`);
|
|
50
|
+
if (/^AC-/i.test(c.caseId))
|
|
51
|
+
fail("case-id-is-ac", `caseId must not be an acceptance id: ${c.caseId}`);
|
|
52
|
+
if (seen.has(c.caseId))
|
|
53
|
+
fail("duplicate-case-id", c.caseId);
|
|
54
|
+
seen.add(c.caseId);
|
|
55
|
+
if (typeof c.dimension !== "string" || !DIMENSIONS.has(c.dimension))
|
|
56
|
+
fail("invalid-dimension", String(c.dimension));
|
|
57
|
+
if (!Array.isArray(c.acIds) ||
|
|
58
|
+
c.acIds.length === 0 ||
|
|
59
|
+
c.acIds.some((a) => typeof a !== "string" || !a.trim()))
|
|
60
|
+
fail("ac-mapping", `invalid acIds for ${c.caseId}`);
|
|
61
|
+
const acIds = [];
|
|
62
|
+
for (const ac of c.acIds) {
|
|
63
|
+
if (typeof ac !== "string" || !acIdRe.test(ac))
|
|
64
|
+
fail("ac-id-shape", `acIds entry must be AC-* acceptance id, not caseId: ${ac}`);
|
|
65
|
+
if (declaredAc.size > 0 && !declaredAc.has(ac))
|
|
66
|
+
fail("unknown-ac", `${ac} not in sourceBinding; repair generator input or AC list`);
|
|
67
|
+
acIds.push(ac);
|
|
68
|
+
}
|
|
69
|
+
const casePath = `testcase/frontend/cases/${c.caseId}.md`;
|
|
70
|
+
const evidenceDir = `testcase/frontend/evidence/${c.caseId}/`;
|
|
71
|
+
for (const [k, v] of [
|
|
72
|
+
["casePath", casePath],
|
|
73
|
+
["evidenceDir", evidenceDir],
|
|
74
|
+
]) {
|
|
75
|
+
if (path.isAbsolute(v) || v.includes(".."))
|
|
76
|
+
fail("unsafe-path", `${k}: ${v}`);
|
|
77
|
+
}
|
|
78
|
+
const casePathAbs = path.join(input.workspaceRoot, casePath);
|
|
79
|
+
const body = await readFile(casePathAbs).catch(() => null);
|
|
80
|
+
if (body === null)
|
|
81
|
+
fail("case-file-missing", `missing case file ${casePath} (filename must equal caseId.md)`);
|
|
82
|
+
if (seenCasePath.has(casePath))
|
|
83
|
+
fail("duplicate-case-path", casePath);
|
|
84
|
+
seenCasePath.add(casePath);
|
|
85
|
+
if (seenEvidenceDir.has(evidenceDir))
|
|
86
|
+
fail("duplicate-evidence-dir", evidenceDir);
|
|
87
|
+
seenEvidenceDir.add(evidenceDir);
|
|
88
|
+
cases.push({
|
|
89
|
+
caseId: c.caseId,
|
|
90
|
+
casePath,
|
|
91
|
+
evidenceDir,
|
|
92
|
+
dimension: c.dimension,
|
|
93
|
+
acIds,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const payload = `${JSON.stringify({ schemaVersion: 1, cases }, null, 2)}\n`;
|
|
97
|
+
await mkdir(path.dirname(tmp), { recursive: true });
|
|
98
|
+
await writeFile(tmp, payload, "utf8");
|
|
99
|
+
await rename(tmp, file);
|
|
100
|
+
await unlink(draft).catch(() => {
|
|
101
|
+
/* draft already absent */
|
|
102
|
+
});
|
|
103
|
+
return { cases, manifestPath: file };
|
|
104
|
+
}
|