@tea-agent/loop-agent 0.25.4 → 0.25.6
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/AGENTS.md +6 -0
- package/CHANGELOG.md +60 -0
- package/dist/commands/client-recovery.js +209 -62
- package/dist/commands/init.js +68 -129
- package/dist/executors/dag-pi-executor.js +80 -15
- package/dist/executors/model-routing.js +1 -1
- package/dist/executors/shell-executor.js +127 -0
- package/dist/executors/shell-write-guard.js +21 -7
- package/dist/worker/console/repo-fingerprint.js +7 -1
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +1281 -0
- package/dist/workflows/dag/backend-test-case-manifest.js +59 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +236 -16
- package/dist/workflows/dag/convergence/controller.js +134 -9
- package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
- package/dist/workflows/dag/init-hybrid.js +270 -80
- package/dist/workflows/dag/node-execution.js +64 -11
- package/dist/workflows/dag/prompt.js +118 -4
- package/dist/workflows/dag/retry-policy.js +5 -4
- package/dist/workflows/dag/scheduler.js +32 -5
- package/dist/workflows/dag/types.js +7 -4
- package/dist/workflows/dag/validate.js +3 -2
- package/docs/architecture/dag-execution.md +7 -4
- package/docs/architecture/runtime-boundaries.md +1 -1
- package/docs/init-surface.manifest.json +3 -1
- package/docs/templates/README.md +1 -0
- package/docs/templates/agent-dag.base.json +1 -1
- package/docs/templates/agent-dag.final-verification.json +1 -1
- package/docs/templates/agent-dag.supervised-implementation.json +1 -1
- package/docs/templates/backend-test-dag.json +41 -14
- package/docs/templates/frontend-test-dag.json +32 -2
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/init-managed-agents.md +137 -0
- package/examples/decision-gate-agent-dag.json +1 -1
- package/examples/example-dag.json +1 -1
- package/examples/hybrid-loop-agent-dag.json +1 -1
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +5 -4
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
- package/skills/loop-agent/references/model-routing.md +1 -1
|
@@ -8,6 +8,16 @@ const DEFAULT_CONVERGENCE_CHAIN_NODE_IDS = [
|
|
|
8
8
|
"repair-pi",
|
|
9
9
|
"hard-verify-shell",
|
|
10
10
|
];
|
|
11
|
+
/**
|
|
12
|
+
* Review chain node ids that the supervised convergence loop may also observe.
|
|
13
|
+
* When these are part of the active convergence chain (via
|
|
14
|
+
* `spec.convergence.chainNodeIds`), a legitimate review `request-revision`
|
|
15
|
+
* re-enters the same bounded repair-reverify-review loop instead of only
|
|
16
|
+
* blocking closeout. Review protocol-invalid failures keep using their own
|
|
17
|
+
* protocol recovery; only a safe `request-revision` drives code repair here.
|
|
18
|
+
*/
|
|
19
|
+
const REVIEW_GATE_NODE_ID = "review-gate-shell";
|
|
20
|
+
const REVIEW_VERDICT_NODE_ID = "review-verdict-recovery-pi";
|
|
11
21
|
const CONVERGENCE_NON_RETRY_FAILURES = new Set([
|
|
12
22
|
"timeout",
|
|
13
23
|
"spawn-error",
|
|
@@ -17,6 +27,11 @@ const CONVERGENCE_NON_RETRY_FAILURES = new Set([
|
|
|
17
27
|
"human-rejected",
|
|
18
28
|
"decision-gate-requires-human",
|
|
19
29
|
]);
|
|
30
|
+
const REVIEW_SOURCE_NON_RETRY_FAILURES = new Set([
|
|
31
|
+
...CONVERGENCE_NON_RETRY_FAILURES,
|
|
32
|
+
"protocol-invalid",
|
|
33
|
+
"invalid-output",
|
|
34
|
+
]);
|
|
20
35
|
export function shouldEnableDagConvergence(spec) {
|
|
21
36
|
return (process.env.HARNESS_DAG_CONVERGENCE !== "off" &&
|
|
22
37
|
spec.convergence?.enabled === true);
|
|
@@ -33,10 +48,22 @@ export async function runConvergencePassController(input) {
|
|
|
33
48
|
convergence.terminalReason = "unsupported-dag-shape";
|
|
34
49
|
return { retry: false };
|
|
35
50
|
}
|
|
51
|
+
const chain = getConvergenceChain(input.spec);
|
|
52
|
+
const observesReview = chain.includes(REVIEW_GATE_NODE_ID);
|
|
36
53
|
const hardVerify = input.state.nodes["hard-verify-shell"];
|
|
37
54
|
if (!hardVerify)
|
|
38
55
|
return { retry: false };
|
|
39
|
-
if (hardVerify.status === "
|
|
56
|
+
if (hardVerify.status === "ERROR") {
|
|
57
|
+
return handleHardVerifyFailure(input, hardVerify);
|
|
58
|
+
}
|
|
59
|
+
if (hardVerify.status !== "FINISHED")
|
|
60
|
+
return { retry: false };
|
|
61
|
+
// Hard verification passed. When the chain observes a review gate, success
|
|
62
|
+
// is gated on the review verdict: a legitimate `request-revision` re-enters
|
|
63
|
+
// the same bounded repair-reverify-review loop (AC3/AC4). Non-supervised
|
|
64
|
+
// DAGs without a review gate in the chain keep the original hard-verify-pass
|
|
65
|
+
// terminal behavior.
|
|
66
|
+
if (!observesReview) {
|
|
40
67
|
convergence.terminalReason = "hard-verify-pass";
|
|
41
68
|
await appendConvergenceKnowledgePattern({
|
|
42
69
|
cwd: input.cwd,
|
|
@@ -44,8 +71,29 @@ export async function runConvergencePassController(input) {
|
|
|
44
71
|
});
|
|
45
72
|
return { retry: false };
|
|
46
73
|
}
|
|
47
|
-
|
|
74
|
+
const reviewGate = input.state.nodes[REVIEW_GATE_NODE_ID];
|
|
75
|
+
if (!reviewGate)
|
|
48
76
|
return { retry: false };
|
|
77
|
+
if (reviewGate.status === "FINISHED") {
|
|
78
|
+
convergence.terminalReason = "review-pass";
|
|
79
|
+
await appendConvergenceKnowledgePattern({
|
|
80
|
+
cwd: input.cwd,
|
|
81
|
+
state: input.state,
|
|
82
|
+
});
|
|
83
|
+
return { retry: false };
|
|
84
|
+
}
|
|
85
|
+
if (reviewGate.status === "ERROR") {
|
|
86
|
+
return handleReviewRequestRevision(input, reviewGate);
|
|
87
|
+
}
|
|
88
|
+
// Review gate still PENDING/SKIPPED mid-rank: wait for the next loop.
|
|
89
|
+
return { retry: false };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Hard verification failed. Apply the existing non-retry / regression /
|
|
93
|
+
* max-passes guards, then reset the convergence chain for another pass.
|
|
94
|
+
*/
|
|
95
|
+
async function handleHardVerifyFailure(input, hardVerify) {
|
|
96
|
+
const convergence = input.state.convergence;
|
|
49
97
|
const currentPass = convergence.currentPass || 1;
|
|
50
98
|
const hardFailure = hardVerify.failureCategory ?? "unknown";
|
|
51
99
|
const passRecord = await buildConvergencePassRecord({
|
|
@@ -97,6 +145,69 @@ export async function runConvergencePassController(input) {
|
|
|
97
145
|
await input.persistState();
|
|
98
146
|
return { retry: true };
|
|
99
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Hard verification passed but the review gate blocked closeout with a
|
|
150
|
+
* legitimate `request-revision` (review-gate-shell ERRORs when the verdict is
|
|
151
|
+
* not `pass`). Re-enter the same bounded recovery chain so repair can address
|
|
152
|
+
* the review findings, then re-verify and re-review. Review protocol-invalid
|
|
153
|
+
* and invalid-output sources, plus safety failures such as auth/write-guard,
|
|
154
|
+
* stay fail-closed and never enter code repair.
|
|
155
|
+
*/
|
|
156
|
+
async function handleReviewRequestRevision(input, reviewGate) {
|
|
157
|
+
const convergence = input.state.convergence;
|
|
158
|
+
const currentPass = convergence.currentPass || 1;
|
|
159
|
+
const reviewNode = input.state.nodes["review-pi"];
|
|
160
|
+
const reviewVerdictNode = input.state.nodes[REVIEW_VERDICT_NODE_ID];
|
|
161
|
+
// The gate commonly reports only nonzero-exit. Inspect the complete review
|
|
162
|
+
// chain so recovery output cannot launder provider/safety/protocol failures
|
|
163
|
+
// into automatic code repair.
|
|
164
|
+
const reviewFailure = [reviewGate, reviewVerdictNode, reviewNode]
|
|
165
|
+
.map((node) => node?.failureCategory)
|
|
166
|
+
.find((category) => category && REVIEW_SOURCE_NON_RETRY_FAILURES.has(category));
|
|
167
|
+
const legitimateRequestRevision = reviewNode?.status === "FINISHED" &&
|
|
168
|
+
parseProcessVerdict(reviewNode) === "request-revision" &&
|
|
169
|
+
reviewVerdictNode?.status === "FINISHED" &&
|
|
170
|
+
parseProcessVerdict(reviewVerdictNode) === "request-revision";
|
|
171
|
+
if (reviewFailure || !legitimateRequestRevision) {
|
|
172
|
+
const passRecord = await buildConvergencePassRecord({
|
|
173
|
+
pass: currentPass,
|
|
174
|
+
status: "terminal",
|
|
175
|
+
reason: "non-retry-failure",
|
|
176
|
+
state: input.state,
|
|
177
|
+
runDir: input.runDir,
|
|
178
|
+
spec: input.spec,
|
|
179
|
+
});
|
|
180
|
+
convergence.passHistory.push(passRecord);
|
|
181
|
+
convergence.terminalReason = "non-retry-failure";
|
|
182
|
+
await input.persistState();
|
|
183
|
+
return { retry: false };
|
|
184
|
+
}
|
|
185
|
+
const passRecord = await buildConvergencePassRecord({
|
|
186
|
+
pass: currentPass,
|
|
187
|
+
status: "retrying",
|
|
188
|
+
reason: "review-request-revision",
|
|
189
|
+
state: input.state,
|
|
190
|
+
runDir: input.runDir,
|
|
191
|
+
spec: input.spec,
|
|
192
|
+
});
|
|
193
|
+
if (currentPass >= convergence.maxPasses) {
|
|
194
|
+
passRecord.status = "terminal";
|
|
195
|
+
passRecord.reason = "max-passes";
|
|
196
|
+
convergence.passHistory.push(passRecord);
|
|
197
|
+
convergence.terminalReason = "max-passes";
|
|
198
|
+
await input.persistState();
|
|
199
|
+
return { retry: false };
|
|
200
|
+
}
|
|
201
|
+
convergence.passHistory.push(passRecord);
|
|
202
|
+
convergence.currentPass = currentPass + 1;
|
|
203
|
+
await resetConvergenceNodesForNextPass({
|
|
204
|
+
spec: input.spec,
|
|
205
|
+
state: input.state,
|
|
206
|
+
tasksById: input.tasksById,
|
|
207
|
+
});
|
|
208
|
+
await input.persistState();
|
|
209
|
+
return { retry: true };
|
|
210
|
+
}
|
|
100
211
|
function getConvergenceChain(spec) {
|
|
101
212
|
return spec.convergence?.chainNodeIds ?? DEFAULT_CONVERGENCE_CHAIN_NODE_IDS;
|
|
102
213
|
}
|
|
@@ -107,6 +218,8 @@ function hasConvergenceChain(tasksById, spec) {
|
|
|
107
218
|
async function buildConvergencePassRecord(input) {
|
|
108
219
|
const hardVerify = input.state.nodes["hard-verify-shell"];
|
|
109
220
|
const processSupervisor = input.state.nodes["process-supervisor-pi"];
|
|
221
|
+
const reviewVerdictNode = input.state.nodes[REVIEW_VERDICT_NODE_ID];
|
|
222
|
+
const reviewGate = input.state.nodes[REVIEW_GATE_NODE_ID];
|
|
110
223
|
const verifyEvidence = hardVerify?.verifyEvidence;
|
|
111
224
|
return {
|
|
112
225
|
pass: input.pass,
|
|
@@ -123,6 +236,9 @@ async function buildConvergencePassRecord(input) {
|
|
|
123
236
|
verifyCommandCount: verifyEvidence?.commandCount,
|
|
124
237
|
verifyCommandLabels: verifyEvidence?.commandLabels,
|
|
125
238
|
shellSuccessCount: countSuccessfulShellCommands(hardVerify?.stdout),
|
|
239
|
+
reviewVerdict: parseProcessVerdict(reviewVerdictNode),
|
|
240
|
+
reviewGateStatus: reviewGate?.status,
|
|
241
|
+
reviewFailureCategory: reviewGate?.failureCategory,
|
|
126
242
|
artifactRefs: await preserveConvergencePassArtifacts({
|
|
127
243
|
pass: input.pass,
|
|
128
244
|
state: input.state,
|
|
@@ -241,10 +357,17 @@ function extractSupervisorStructuredBlock(node) {
|
|
|
241
357
|
// Legacy compatibility only. New supervisor prompts must emit REPAIR_ARTIFACT_JSON.
|
|
242
358
|
const text = `${node?.assistantText ?? ""}\n${node?.stdout ?? ""}`;
|
|
243
359
|
const block = {};
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
360
|
+
const acceptedKeys = new Set(["FAILURE_CLASS", "FIX_SCOPE", "INVARIANT"]);
|
|
361
|
+
for (const line of text.split(/\r?\n/)) {
|
|
362
|
+
const separatorIndex = line.indexOf(":");
|
|
363
|
+
if (separatorIndex <= 0)
|
|
364
|
+
continue;
|
|
365
|
+
const key = line.slice(0, separatorIndex).trim().toUpperCase();
|
|
366
|
+
if (!acceptedKeys.has(key))
|
|
367
|
+
continue;
|
|
368
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
369
|
+
if (value)
|
|
370
|
+
block[key] = value;
|
|
248
371
|
}
|
|
249
372
|
return block;
|
|
250
373
|
}
|
|
@@ -252,9 +375,11 @@ async function resetConvergenceNodesForNextPass(input) {
|
|
|
252
375
|
const chain = getConvergenceChain(input.spec);
|
|
253
376
|
const resetIds = new Set(chain);
|
|
254
377
|
for (const id of collectTransitiveDescendantTaskIds(input.spec, "hard-verify-shell")) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
378
|
+
// A new repair pass invalidates every downstream result, including nodes
|
|
379
|
+
// that already FINISHED (for example authority audit or failure-aware
|
|
380
|
+
// closeout). Reset the complete controlled descendant closure so the next
|
|
381
|
+
// pass cannot reuse stale governance or handoff evidence.
|
|
382
|
+
resetIds.add(id);
|
|
258
383
|
}
|
|
259
384
|
for (const id of resetIds) {
|
|
260
385
|
const task = input.tasksById.get(id);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { frontendTestResultContractSchema, } from "./frontend-test-result-contract.js";
|
|
4
|
+
function ratioMetric(numerator, denominator, threshold, label) {
|
|
5
|
+
if (denominator === 0) {
|
|
6
|
+
return { numerator, denominator, ratio: null, threshold, status: "unavailable", reason: `${label}-denominator-is-zero` };
|
|
7
|
+
}
|
|
8
|
+
const ratio = numerator / denominator;
|
|
9
|
+
return {
|
|
10
|
+
numerator,
|
|
11
|
+
denominator,
|
|
12
|
+
ratio,
|
|
13
|
+
threshold,
|
|
14
|
+
status: ratio >= threshold ? "pass" : "fail",
|
|
15
|
+
reason: ratio >= threshold ? null : `${label}-below-threshold`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function unavailable(reason, threshold) {
|
|
19
|
+
return { numerator: null, denominator: null, ratio: null, threshold, status: "unavailable", reason };
|
|
20
|
+
}
|
|
21
|
+
/** Compute the shared L-5 gates from the authoritative frontend result contract. */
|
|
22
|
+
export function computeFrontendL5ReportMetrics(result) {
|
|
23
|
+
const executed = result.totals.passed + result.totals.failed;
|
|
24
|
+
const acTotal = result.acceptanceCoverage.covered.length + result.acceptanceCoverage.missing.length;
|
|
25
|
+
const automationExecuted = executed;
|
|
26
|
+
const criticalRiskCount = result.acceptanceCoverage.missing.length + result.advisoryFindings.filter((finding) => /critical|blocking|unsafe|missing-ac/i.test(finding.ruleId)).length;
|
|
27
|
+
const metrics = {
|
|
28
|
+
passRate: ratioMetric(result.totals.passed, executed, 1, "pass-rate"),
|
|
29
|
+
acCoverage: ratioMetric(result.acceptanceCoverage.covered.length, acTotal, 1, "ac-coverage"),
|
|
30
|
+
automationCoverage: ratioMetric(automationExecuted, result.totals.cases, 0.9, "automation-coverage"),
|
|
31
|
+
lineCoverage: unavailable("frontend-code-coverage-missing", 0.8),
|
|
32
|
+
branchCoverage: unavailable("frontend-code-coverage-missing", 0.7),
|
|
33
|
+
skipped: {
|
|
34
|
+
numerator: result.totals.blocked,
|
|
35
|
+
denominator: result.totals.blocked,
|
|
36
|
+
ratio: result.totals.blocked === 0 ? 1 : 0,
|
|
37
|
+
threshold: 1,
|
|
38
|
+
status: result.totals.blocked === 0 ? "pass" : "fail",
|
|
39
|
+
reason: result.totals.blocked === 0 ? null : "skipped-tests-present",
|
|
40
|
+
},
|
|
41
|
+
criticalRisks: {
|
|
42
|
+
numerator: criticalRiskCount,
|
|
43
|
+
denominator: criticalRiskCount,
|
|
44
|
+
ratio: criticalRiskCount === 0 ? 1 : 0,
|
|
45
|
+
threshold: 1,
|
|
46
|
+
status: criticalRiskCount === 0 ? "pass" : "fail",
|
|
47
|
+
reason: criticalRiskCount === 0 ? null : "critical-risk-present",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
const blockingItems = Object.entries(metrics)
|
|
51
|
+
.filter(([, metric]) => metric.status !== "pass")
|
|
52
|
+
.map(([name, metric]) => `${name}:${metric.reason ?? metric.status}`);
|
|
53
|
+
return {
|
|
54
|
+
status: blockingItems.length === 0 ? "ready" : "not-ready",
|
|
55
|
+
metrics,
|
|
56
|
+
blockingItems,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function escapeHtml(value) {
|
|
60
|
+
return String(value ?? "")
|
|
61
|
+
.replaceAll("&", "&")
|
|
62
|
+
.replaceAll("<", "<")
|
|
63
|
+
.replaceAll(">", ">")
|
|
64
|
+
.replaceAll('"', """)
|
|
65
|
+
.replaceAll("'", "'");
|
|
66
|
+
}
|
|
67
|
+
function metricValue(metric) {
|
|
68
|
+
if (metric.ratio === null)
|
|
69
|
+
return "unavailable";
|
|
70
|
+
return `${(metric.ratio * 100).toFixed(1)}%`;
|
|
71
|
+
}
|
|
72
|
+
function metricRow(label, metric) {
|
|
73
|
+
return `| ${label} | ${metric.numerator ?? "-"} / ${metric.denominator ?? "-"} | ${metricValue(metric)} | ${metric.status.toUpperCase()} | ${metric.reason ?? "-"} |`;
|
|
74
|
+
}
|
|
75
|
+
function renderMarkdown(result, metrics) {
|
|
76
|
+
const m = metrics.metrics;
|
|
77
|
+
return [
|
|
78
|
+
"# 前端测试 L-5 报告",
|
|
79
|
+
"",
|
|
80
|
+
`- L-5 结论:**${metrics.status === "ready" ? "READY" : "NOT READY"}**`,
|
|
81
|
+
`- Result v1 outcome:${result.outcome}`,
|
|
82
|
+
"",
|
|
83
|
+
"| 指标 | 分子 / 分母 | 比例 | 状态 | 原因 |",
|
|
84
|
+
"| --- | ---: | ---: | --- | --- |",
|
|
85
|
+
metricRow("测试通过率", m.passRate),
|
|
86
|
+
metricRow("AC 验收覆盖", m.acCoverage),
|
|
87
|
+
metricRow("自动化覆盖率", m.automationCoverage),
|
|
88
|
+
metricRow("Line 代码覆盖", m.lineCoverage),
|
|
89
|
+
metricRow("Branch 代码覆盖", m.branchCoverage),
|
|
90
|
+
metricRow("阻塞/跳过用例", m.skipped),
|
|
91
|
+
metricRow("Critical 风险", m.criticalRisks),
|
|
92
|
+
"",
|
|
93
|
+
"## 判定规则",
|
|
94
|
+
"",
|
|
95
|
+
"测试通过率=100%、AC 覆盖=100%、自动化覆盖率≥90%、Line≥80%、Branch≥70%、阻塞/跳过=0 且无 Critical 风险时才为 READY。",
|
|
96
|
+
"前端 DAG 当前没有可信的 line/branch 代码覆盖产物,因此这两项保持 unavailable,不得推断。",
|
|
97
|
+
"",
|
|
98
|
+
"## 阻塞项",
|
|
99
|
+
"",
|
|
100
|
+
...(metrics.blockingItems.length ? metrics.blockingItems.map((item) => `- ${item}`) : ["- 无"]),
|
|
101
|
+
"",
|
|
102
|
+
].join("\n");
|
|
103
|
+
}
|
|
104
|
+
function renderHtml(result, metrics) {
|
|
105
|
+
const rows = Object.entries(metrics.metrics)
|
|
106
|
+
.map(([name, metric]) => `<tr><td>${escapeHtml(name)}</td><td>${metric.numerator ?? "-"} / ${metric.denominator ?? "-"}</td><td>${escapeHtml(metricValue(metric))}</td><td class="${metric.status}">${escapeHtml(metric.status.toUpperCase())}</td><td>${escapeHtml(metric.reason ?? "-")}</td></tr>`)
|
|
107
|
+
.join("");
|
|
108
|
+
const blocking = metrics.blockingItems.length
|
|
109
|
+
? metrics.blockingItems.map((item) => `<li>${escapeHtml(item)}</li>`).join("")
|
|
110
|
+
: "<li>无</li>";
|
|
111
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none';style-src 'unsafe-inline'"><title>前端测试 L-5 报告</title><style>body{font:15px system-ui,"Microsoft YaHei",sans-serif;background:#f5f7fb;color:#172033;margin:0}main{max-width:1100px;margin:auto;padding:32px}.card{background:#fff;border-radius:14px;padding:24px;margin:16px 0;box-shadow:0 6px 24px #10182812}.decision{font-size:28px;font-weight:800;color:${metrics.status === "ready" ? "#067647" : "#b42318"}}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:12px;border-bottom:1px solid #e4e7ec}.pass{color:#067647}.fail{color:#b42318}.unavailable{color:#946200}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}</style></head><body><main><section class="card"><h1>前端测试 L-5 报告</h1><div class="decision">${metrics.status === "ready" ? "READY" : "NOT READY"}</div><p>Result v1 outcome:<code>${escapeHtml(result.outcome)}</code></p></section><section class="card"><h2>指标</h2><table><thead><tr><th>指标</th><th>分子 / 分母</th><th>比例</th><th>状态</th><th>原因</th></tr></thead><tbody>${rows}</tbody></table></section><section class="card"><h2>阻塞项</h2><ul>${blocking}</ul><p>前端 DAG 当前没有可信的 line/branch 代码覆盖产物,因此这两项为 unavailable,不得推断。</p></section></main></body></html>`;
|
|
112
|
+
}
|
|
113
|
+
async function writePairAtomic(markdownPath, markdown, htmlPath, html) {
|
|
114
|
+
await mkdir(path.dirname(markdownPath), { recursive: true });
|
|
115
|
+
const nonce = `${process.pid}-${Date.now()}`;
|
|
116
|
+
const markdownTemp = `${markdownPath}.${nonce}.tmp`;
|
|
117
|
+
const htmlTemp = `${htmlPath}.${nonce}.tmp`;
|
|
118
|
+
try {
|
|
119
|
+
await writeFile(markdownTemp, markdown, "utf8");
|
|
120
|
+
await writeFile(htmlTemp, html, "utf8");
|
|
121
|
+
await rename(markdownTemp, markdownPath);
|
|
122
|
+
await rename(htmlTemp, htmlPath);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await rm(markdownTemp, { force: true });
|
|
126
|
+
await rm(htmlTemp, { force: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export async function renderFrontendTestL5Report(input) {
|
|
130
|
+
const resultPath = path.join(input.runDir, "contracts", "frontend-test-result.json");
|
|
131
|
+
const result = frontendTestResultContractSchema.parse(JSON.parse(await readFile(resultPath, "utf8")));
|
|
132
|
+
const metrics = computeFrontendL5ReportMetrics(result);
|
|
133
|
+
const outputDir = path.join(input.workspaceRoot, "testcase", "frontend", "reports");
|
|
134
|
+
const markdownPath = path.join(outputDir, "frontend-test-l5-dashboard.md");
|
|
135
|
+
const htmlPath = path.join(outputDir, "frontend-test-l5-dashboard.html");
|
|
136
|
+
await writePairAtomic(markdownPath, renderMarkdown(result, metrics), htmlPath, renderHtml(result, metrics));
|
|
137
|
+
return { metrics, markdownPath, htmlPath };
|
|
138
|
+
}
|