@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.
- package/AGENTS.md +6 -0
- package/CHANGELOG.md +55 -0
- package/dist/application/dag/args.js +21 -1
- package/dist/application/dag/run-dag.js +1 -0
- package/dist/cli/command-definitions.js +1 -1
- package/dist/cli/program.js +82 -63
- package/dist/commands/client-recovery.js +209 -62
- package/dist/commands/init.js +206 -82
- package/dist/commands/run-dag-progress.js +109 -0
- package/dist/commands/run-dag.js +16 -5
- package/dist/executors/dag-pi-executor.js +80 -15
- package/dist/executors/model-routing.js +1 -1
- package/dist/executors/shell-executor.js +159 -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 +964 -0
- package/dist/workflows/dag/backend-test-case-manifest.js +39 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +306 -30
- package/dist/workflows/dag/backend-test-result-contract.js +35 -9
- package/dist/workflows/dag/convergence/controller.js +134 -9
- package/dist/workflows/dag/frontend-test-case-checklist.js +71 -0
- package/dist/workflows/dag/frontend-test-html-report.js +77 -0
- package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +44 -1
- package/dist/workflows/dag/init-hybrid.js +267 -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 +10 -3
- package/dist/workflows/dag/validate.js +6 -3
- package/docs/architecture/dag-execution.md +7 -4
- package/docs/architecture/runtime-boundaries.md +1 -1
- package/docs/templates/agent-dag.base.json +1 -1
- package/docs/templates/agent-dag.final-verification.json +1 -1
- package/docs/templates/agent-dag.schema.json +6 -0
- package/docs/templates/agent-dag.supervised-implementation.json +1 -1
- package/docs/templates/backend-test-dag.json +40 -13
- package/docs/templates/backend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.json +62 -5
- package/docs/templates/hybrid-dag.json +1 -1
- 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 +3 -2
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +2 -1
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
- package/skills/loop-agent/references/model-routing.md +1 -1
|
@@ -359,12 +359,36 @@ const HTML_ENTITY_MAP = {
|
|
|
359
359
|
"<": "<",
|
|
360
360
|
">": ">",
|
|
361
361
|
""": '"',
|
|
362
|
-
""": '"',
|
|
363
|
-
"'": "'",
|
|
364
362
|
"'": "'",
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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. `"`
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 === "
|
|
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,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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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("&", "&")
|
|
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
|
+
}
|