@tea-agent/loop-agent 0.25.3 → 0.25.5

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.
Files changed (49) hide show
  1. package/AGENTS.md +6 -0
  2. package/CHANGELOG.md +55 -0
  3. package/dist/application/dag/args.js +21 -1
  4. package/dist/application/dag/run-dag.js +1 -0
  5. package/dist/cli/command-definitions.js +1 -1
  6. package/dist/cli/program.js +82 -63
  7. package/dist/commands/client-recovery.js +209 -62
  8. package/dist/commands/init.js +206 -82
  9. package/dist/commands/run-dag-progress.js +109 -0
  10. package/dist/commands/run-dag.js +16 -5
  11. package/dist/executors/dag-pi-executor.js +80 -15
  12. package/dist/executors/model-routing.js +1 -1
  13. package/dist/executors/shell-executor.js +159 -0
  14. package/dist/executors/shell-write-guard.js +21 -7
  15. package/dist/worker/console/repo-fingerprint.js +7 -1
  16. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +964 -0
  17. package/dist/workflows/dag/backend-test-case-manifest.js +39 -1
  18. package/dist/workflows/dag/backend-test-markdown-workflow.js +306 -30
  19. package/dist/workflows/dag/backend-test-result-contract.js +35 -9
  20. package/dist/workflows/dag/convergence/controller.js +134 -9
  21. package/dist/workflows/dag/frontend-test-case-checklist.js +71 -0
  22. package/dist/workflows/dag/frontend-test-html-report.js +77 -0
  23. package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
  24. package/dist/workflows/dag/frontend-test-result-contract.js +44 -1
  25. package/dist/workflows/dag/init-hybrid.js +267 -80
  26. package/dist/workflows/dag/node-execution.js +64 -11
  27. package/dist/workflows/dag/prompt.js +118 -4
  28. package/dist/workflows/dag/retry-policy.js +5 -4
  29. package/dist/workflows/dag/scheduler.js +32 -5
  30. package/dist/workflows/dag/types.js +10 -3
  31. package/dist/workflows/dag/validate.js +6 -3
  32. package/docs/architecture/dag-execution.md +7 -4
  33. package/docs/architecture/runtime-boundaries.md +1 -1
  34. package/docs/templates/agent-dag.base.json +1 -1
  35. package/docs/templates/agent-dag.final-verification.json +1 -1
  36. package/docs/templates/agent-dag.schema.json +6 -0
  37. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  38. package/docs/templates/backend-test-dag.json +40 -13
  39. package/docs/templates/backend-test-dag.review-cases.prompt.md +1 -1
  40. package/docs/templates/frontend-test-dag.json +62 -5
  41. package/docs/templates/hybrid-dag.json +1 -1
  42. package/examples/decision-gate-agent-dag.json +1 -1
  43. package/examples/example-dag.json +1 -1
  44. package/examples/hybrid-loop-agent-dag.json +1 -1
  45. package/harness.json +3 -2
  46. package/package.json +1 -1
  47. package/skills/loop-agent/references/command-reference.md +2 -1
  48. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  49. package/skills/loop-agent/references/model-routing.md +1 -1
@@ -359,12 +359,36 @@ const HTML_ENTITY_MAP = {
359
359
  "&lt;": "<",
360
360
  "&gt;": ">",
361
361
  "&quot;": '"',
362
- "&#34;": '"',
363
- "&#39;": "'",
364
362
  "&apos;": "'",
365
363
  };
364
+ /** Decode one HTML entity layer. Safe for the outer data-jsonblob attribute. */
366
365
  function decodeHtmlEntities(value) {
367
- return value.replace(/&(?:amp|lt|gt|quot|#34|#39|apos);/g, (entity) => HTML_ENTITY_MAP[entity] ?? entity);
366
+ return value.replace(/&(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);/gi, (entity) => {
367
+ const named = HTML_ENTITY_MAP[entity.toLowerCase()];
368
+ if (named !== undefined)
369
+ return named;
370
+ const hex = entity.match(/^&#x([0-9a-f]+);$/i)?.[1];
371
+ const decimal = entity.match(/^&#(\d+);$/)?.[1];
372
+ const codePoint = hex
373
+ ? Number.parseInt(hex, 16)
374
+ : decimal
375
+ ? Number.parseInt(decimal, 10)
376
+ : Number.NaN;
377
+ return Number.isFinite(codePoint)
378
+ ? String.fromCodePoint(codePoint)
379
+ : entity;
380
+ });
381
+ }
382
+ /** Decode nested entities only after JSON parsing; bounded to avoid overwork. */
383
+ function decodeHtmlEntitiesDeep(value) {
384
+ let current = value;
385
+ for (let index = 0; index < 3; index += 1) {
386
+ const decoded = decodeHtmlEntities(current);
387
+ if (decoded === current)
388
+ break;
389
+ current = decoded;
390
+ }
391
+ return current;
368
392
  }
369
393
  function parseDurationLabelMs(raw) {
370
394
  if (!raw)
@@ -432,6 +456,7 @@ export function parsePytestHtmlReport(html) {
432
456
  const filePathFields = filePath ? { filePath } : {};
433
457
  const durationMs = parseDurationLabelMs(record.duration);
434
458
  const capturedLog = record.log ?? "";
459
+ const decodedCapturedLog = decodeHtmlEntitiesDeep(capturedLog);
435
460
  // pytest-html collapses captured stdout/stderr into a single `log` field
436
461
  // annotated with section markers. Preserve the whole log as stdout so the
437
462
  // HTTP_REQUEST/HTTP_RESPONSE lines stay reachable for the per-case card.
@@ -462,7 +487,7 @@ export function parsePytestHtmlReport(html) {
462
487
  durationMs,
463
488
  status: "failure",
464
489
  message: summary,
465
- details: capturedLog || summary,
490
+ details: decodedCapturedLog || summary,
466
491
  ...(stdout ? { stdout } : {}),
467
492
  ...(stderr ? { stderr } : {}),
468
493
  });
@@ -479,7 +504,7 @@ export function parsePytestHtmlReport(html) {
479
504
  durationMs,
480
505
  status: "error",
481
506
  message: summary,
482
- details: capturedLog || summary,
507
+ details: decodedCapturedLog || summary,
483
508
  ...(stdout ? { stdout } : {}),
484
509
  ...(stderr ? { stderr } : {}),
485
510
  });
@@ -509,7 +534,7 @@ export function parsePytestHtmlReport(html) {
509
534
  durationMs,
510
535
  status: "error",
511
536
  message: summary,
512
- details: capturedLog || summary,
537
+ details: decodedCapturedLog || summary,
513
538
  ...(stdout ? { stdout } : {}),
514
539
  ...(stderr ? { stderr } : {}),
515
540
  });
@@ -572,7 +597,7 @@ function splitPytestHtmlLogSections(log) {
572
597
  // pytest-html HTML-entity-escapes the captured log content (e.g. `&quot;`
573
598
  // for `"`). Decode entities first so downstream HTTP log parsers see the
574
599
  // real JSON/kv payload rather than escaped markup.
575
- const decoded = decodeHtmlEntities(log);
600
+ const decoded = decodeHtmlEntitiesDeep(log);
576
601
  // pytest-html interleaves captured stdout/stderr with section markers like
577
602
  // "----------------------------- Captured stdout call -----------------------------".
578
603
  // String.split includes capture-group matches in the result array, so the
@@ -601,16 +626,17 @@ function splitPytestHtmlLogSections(log) {
601
626
  function extractPytestHtmlFailureMessage(log) {
602
627
  if (!log)
603
628
  return "";
629
+ const decoded = decodeHtmlEntitiesDeep(log);
604
630
  // pytest-html failure logs contain assertion lines prefixed with "E " and a
605
631
  // trailing location line like "test_x.py:N: AssertionError". Prefer the
606
632
  // explicit AssertionError/Error line; fall back to the last "E " line.
607
- const assertionLine = log
633
+ const assertionLine = decoded
608
634
  .split(/\r?\n/)
609
635
  .map((line) => line.trim())
610
636
  .find((line) => /:\s*AssertionError/.test(line));
611
637
  if (assertionLine)
612
638
  return assertionLine.replace(/^.*?:\s*/, "");
613
- const eLines = log
639
+ const eLines = decoded
614
640
  .split(/\r?\n/)
615
641
  .filter((line) => /^E\s+/.test(line))
616
642
  .map((line) => line.replace(/^E\s+/, "").trim());
@@ -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 === "FINISHED") {
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
- if (hardVerify.status !== "ERROR")
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
- for (const key of ["FAILURE_CLASS", "FIX_SCOPE", "INVARIANT"]) {
245
- const match = text.match(new RegExp(`^${key}:\\s*(.+)$`, "im"));
246
- if (match?.[1])
247
- block[key] = match[1].trim();
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
- const node = input.state.nodes[id];
256
- if (node?.status === "SKIPPED")
257
- resetIds.add(id);
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,71 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ async function exists(file) {
4
+ try {
5
+ return (await stat(file)).isFile();
6
+ }
7
+ catch {
8
+ return false;
9
+ }
10
+ }
11
+ export async function validateFrontendCaseChecklist(input) {
12
+ const root = path.join(input.workspaceRoot, "testcase/frontend/cases");
13
+ const draft = path.join(root, "manifest.draft.json");
14
+ const final = path.join(root, "manifest.json");
15
+ const manifestPath = await exists(draft) ? draft : await exists(final) ? final : "";
16
+ if (!manifestPath)
17
+ throw new Error("checklist: missing manifest.draft.json or manifest.json");
18
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
19
+ if (!Array.isArray(manifest.cases) || manifest.cases.length === 0)
20
+ throw new Error("checklist: empty cases");
21
+ const declaredAc = new Set(input.declaredAcIds ?? []);
22
+ const issues = [];
23
+ const caseIdRe = /^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;
24
+ const acIdRe = /^AC(?:-[A-Z0-9]+)+$/i;
25
+ const openRe = /playwright-cli\s+open\s+--browser=chrome\s+--headed\s+https?:\/\/\S+/i;
26
+ const productionHostRe = /(^|[.-])(prod|production)([.-]|$)/i;
27
+ for (const raw of manifest.cases) {
28
+ const item = raw;
29
+ const id = typeof item?.caseId === "string" ? item.caseId : "?";
30
+ if (typeof item?.caseId !== "string" || !caseIdRe.test(item.caseId))
31
+ issues.push({ ruleId: "case-id-shape", caseId: id, detail: "caseId must be FE-<FEATURE>-<NNN>-<dimension>, never AC-FE-*" });
32
+ if (typeof item?.caseId === "string" && /^AC-/i.test(item.caseId))
33
+ issues.push({ ruleId: "case-id-is-ac", caseId: id, detail: "caseId must not be an acceptance id" });
34
+ const casePath = typeof item?.casePath === "string" ? item.casePath : "";
35
+ const expectedPath = typeof item?.caseId === "string" ? `testcase/frontend/cases/${item.caseId}.md` : "";
36
+ const absolute = path.resolve(input.workspaceRoot, casePath);
37
+ const relative = path.relative(input.workspaceRoot, absolute);
38
+ if (!casePath || relative.startsWith("..") || path.isAbsolute(relative) || !(await exists(absolute))) {
39
+ issues.push({ ruleId: "case-file-missing", caseId: id, detail: casePath || "missing casePath" });
40
+ continue;
41
+ }
42
+ if (expectedPath && casePath.replaceAll("\\", "/") !== expectedPath)
43
+ issues.push({ ruleId: "case-path-mismatch", caseId: id, detail: `${casePath} must equal ${expectedPath}` });
44
+ const body = await readFile(absolute, "utf8");
45
+ if (!openRe.test(body))
46
+ issues.push({ ruleId: "open-prefix", caseId: id, detail: "missing playwright-cli open --browser=chrome --headed <absolute-url>" });
47
+ const match = body.match(/playwright-cli\s+open\s+--browser=chrome\s+--headed\s+(https?:\/\/\S+)/i);
48
+ if (match) {
49
+ try {
50
+ const url = new URL(match[1].replace(/[)\]},.\"'`]+$/, ""));
51
+ if (productionHostRe.test(url.hostname))
52
+ issues.push({ ruleId: "production-url", caseId: id, detail: match[1] });
53
+ }
54
+ catch {
55
+ issues.push({ ruleId: "production-url", caseId: id, detail: match[1] });
56
+ }
57
+ }
58
+ if (!Array.isArray(item?.acIds) || item.acIds.length === 0) {
59
+ issues.push({ ruleId: "ac-mapping", caseId: id, detail: "acIds required" });
60
+ }
61
+ else {
62
+ for (const ac of item.acIds) {
63
+ if (typeof ac !== "string" || !acIdRe.test(ac))
64
+ issues.push({ ruleId: "ac-id-shape", caseId: id, detail: String(ac) });
65
+ else if (declaredAc.size > 0 && !declaredAc.has(ac))
66
+ issues.push({ ruleId: "unknown-ac", caseId: id, detail: `${ac} not in sourceBinding` });
67
+ }
68
+ }
69
+ }
70
+ return { caseCount: manifest.cases.length, manifestPath, issues };
71
+ }
@@ -0,0 +1,77 @@
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 escapeHtml(value) {
5
+ return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
6
+ }
7
+ function statusLabel(status) {
8
+ return status === "passed" ? "通过" : status === "failed" ? "失败" : "阻塞";
9
+ }
10
+ function listMarkdown(items) {
11
+ return items.length ? items.map((item, index) => `${index + 1}. ${item}`).join("\n") : "- 无";
12
+ }
13
+ function listHtml(items) {
14
+ return items.length ? `<ol>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ol>` : "<p>无</p>";
15
+ }
16
+ async function writePairAtomic(markdownPath, markdown, htmlPath, html) {
17
+ await mkdir(path.dirname(markdownPath), { recursive: true });
18
+ const nonce = `${process.pid}-${Date.now()}`;
19
+ const markdownTemp = `${markdownPath}.${nonce}.tmp`;
20
+ const htmlTemp = `${htmlPath}.${nonce}.tmp`;
21
+ try {
22
+ await writeFile(markdownTemp, markdown, "utf8");
23
+ await writeFile(htmlTemp, html, "utf8");
24
+ await rename(markdownTemp, markdownPath);
25
+ await rename(htmlTemp, htmlPath);
26
+ }
27
+ finally {
28
+ await rm(markdownTemp, { force: true });
29
+ await rm(htmlTemp, { force: true });
30
+ }
31
+ }
32
+ export async function renderFrontendTestHtmlReport(input) {
33
+ const resultPath = path.join(input.runDir, "contracts", "frontend-test-result.json");
34
+ const result = frontendTestResultContractSchema.parse(JSON.parse(await readFile(resultPath, "utf8")));
35
+ const outputDir = path.join(input.workspaceRoot, "testcase", "frontend", "reports");
36
+ const markdownPath = path.join(outputDir, "frontend-test-report.md");
37
+ const htmlPath = path.join(outputDir, "frontend-test-report.html");
38
+ const outcomeLabel = result.outcome === "passed" ? "测试通过" : result.outcome === "failed" ? "测试失败" : "测试未完成";
39
+ const markdownCases = result.cases.map((item) => [
40
+ `## ${item.caseId}`,
41
+ "",
42
+ `- 执行结果:${statusLabel(item.status)}`,
43
+ "",
44
+ "### 测试目的",
45
+ "",
46
+ item.caseContent.purpose,
47
+ "",
48
+ "### 前置条件",
49
+ "",
50
+ listMarkdown(item.caseContent.preconditions),
51
+ "",
52
+ "### 操作步骤",
53
+ "",
54
+ listMarkdown(item.caseContent.steps),
55
+ "",
56
+ "### 预期结果",
57
+ "",
58
+ listMarkdown(item.caseContent.expectedResults),
59
+ ...(item.status === "passed" ? [] : ["", "### 错误分析", "", item.errorAnalysis ?? `用例因 ${item.blockedReason ?? "未知原因"} 未能完成。`]),
60
+ ].join("\n")).join("\n\n");
61
+ const markdown = [
62
+ "# 前端功能测试报告",
63
+ "",
64
+ `- 测试结论:${outcomeLabel}`,
65
+ `- 用例总数:${result.totals.cases}`,
66
+ `- 通过:${result.totals.passed}`,
67
+ `- 失败:${result.totals.failed}`,
68
+ `- 阻塞:${result.totals.blocked}`,
69
+ "",
70
+ markdownCases,
71
+ "",
72
+ ].join("\n");
73
+ const htmlCases = result.cases.map((item) => `<section class="case ${item.status}"><header><h2>${escapeHtml(item.caseId)}</h2><span class="status">${statusLabel(item.status)}</span></header><h3>测试目的</h3><p>${escapeHtml(item.caseContent.purpose)}</p><h3>前置条件</h3>${listHtml(item.caseContent.preconditions)}<h3>操作步骤</h3>${listHtml(item.caseContent.steps)}<h3>预期结果</h3>${listHtml(item.caseContent.expectedResults)}${item.status === "passed" ? "" : `<h3>错误分析</h3><p class="error">${escapeHtml(item.errorAnalysis ?? `用例因 ${item.blockedReason ?? "未知原因"} 未能完成。`)}</p>`}</section>`).join("");
74
+ const html = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>前端功能测试报告</title><style>body{font:16px system-ui,"Microsoft YaHei",sans-serif;margin:0;background:#f5f7fb;color:#172033}main{max-width:1100px;margin:auto;padding:32px}.summary,.case{background:#fff;border-radius:14px;padding:22px;margin:16px 0;box-shadow:0 6px 24px rgba(16,24,40,.07)}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.metric{background:#f8fafc;padding:14px;border-radius:10px}.case header{display:flex;justify-content:space-between;gap:16px}.status{font-weight:700}.passed .status{color:#067647}.failed .status,.error{color:#b42318}.blocked .status{color:#946200}li{margin:.45rem 0}@media(max-width:700px){.metrics{grid-template-columns:1fr 1fr}}</style></head><body><main><h1>前端功能测试报告</h1><section class="summary"><h2>${outcomeLabel}</h2><div class="metrics"><div class="metric">用例总数<br><strong>${result.totals.cases}</strong></div><div class="metric">通过<br><strong>${result.totals.passed}</strong></div><div class="metric">失败<br><strong>${result.totals.failed}</strong></div><div class="metric">阻塞<br><strong>${result.totals.blocked}</strong></div></div></section>${htmlCases}</main></body></html>`;
75
+ await writePairAtomic(markdownPath, markdown, htmlPath, html);
76
+ return { markdownPath, htmlPath, outcome: result.outcome, caseCount: result.cases.length };
77
+ }
@@ -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("&", "&amp;")
62
+ .replaceAll("<", "&lt;")
63
+ .replaceAll(">", "&gt;")
64
+ .replaceAll('"', "&quot;")
65
+ .replaceAll("'", "&#39;");
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
+ }