@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
|
@@ -44,7 +44,7 @@ const gapSchema = z
|
|
|
44
44
|
const caseSchema = z
|
|
45
45
|
.object({
|
|
46
46
|
caseId: z.string().regex(BACKEND_TEST_CASE_ID_PATTERN),
|
|
47
|
-
acIds: z.array(z.string().regex(BACKEND_TEST_AC_ID_PATTERN))
|
|
47
|
+
acIds: z.array(z.string().regex(BACKEND_TEST_AC_ID_PATTERN)),
|
|
48
48
|
title: z.string().min(1),
|
|
49
49
|
category: backendTestCaseCategorySchema,
|
|
50
50
|
automationStatus: backendTestCaseAutomationStatusSchema,
|
|
@@ -96,6 +96,44 @@ export const backendTestCaseManifestSchema = z
|
|
|
96
96
|
requirementIds: z.array(z.string().regex(/^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/)),
|
|
97
97
|
})
|
|
98
98
|
.strict(),
|
|
99
|
+
materializationStatus: z.enum(["available", "partial", "unavailable"]).optional(),
|
|
100
|
+
sourceFactsIssues: z.array(z.string().min(1)).optional(),
|
|
101
|
+
ruleCoverageSummary: z.object({
|
|
102
|
+
explicitAcCount: z.number().int().min(0),
|
|
103
|
+
coveredAcCount: z.number().int().min(0),
|
|
104
|
+
ruleCount: z.number().int().min(0),
|
|
105
|
+
coveredRuleCount: z.number().int().min(0),
|
|
106
|
+
testPointCount: z.number().int().min(0),
|
|
107
|
+
coveredTestPointCount: z.number().int().min(0),
|
|
108
|
+
enumValueCount: z.number().int().min(0),
|
|
109
|
+
coveredEnumValueCount: z.number().int().min(0),
|
|
110
|
+
invalidEquivalenceClassCount: z.number().int().min(0),
|
|
111
|
+
coveredInvalidEquivalenceClassCount: z.number().int().min(0),
|
|
112
|
+
boundaryPointCount: z.number().int().min(0),
|
|
113
|
+
coveredBoundaryPointCount: z.number().int().min(0),
|
|
114
|
+
formatClassCount: z.number().int().min(0),
|
|
115
|
+
coveredFormatClassCount: z.number().int().min(0),
|
|
116
|
+
businessStateCount: z.number().int().min(0),
|
|
117
|
+
coveredBusinessStateCount: z.number().int().min(0),
|
|
118
|
+
gapCount: z.number().int().min(0),
|
|
119
|
+
conflictCount: z.number().int().min(0),
|
|
120
|
+
}).strict().optional(),
|
|
121
|
+
correspondenceSummary: z.object({
|
|
122
|
+
markdownModuleCount: z.number().int().min(0),
|
|
123
|
+
exactModuleCount: z.number().int().min(0),
|
|
124
|
+
markdownCaseCount: z.number().int().min(0),
|
|
125
|
+
exactCorrespondenceCount: z.number().int().min(0),
|
|
126
|
+
missingPytestCount: z.number().int().min(0),
|
|
127
|
+
multiplePytestCount: z.number().int().min(0),
|
|
128
|
+
extraPytestCount: z.number().int().min(0),
|
|
129
|
+
scriptMismatchCount: z.number().int().min(0),
|
|
130
|
+
testPointCount: z.number().int().min(0),
|
|
131
|
+
mappedTestPointCount: z.number().int().min(0),
|
|
132
|
+
}).strict().optional(),
|
|
133
|
+
artifactRefs: z.object({
|
|
134
|
+
caseCoverageFacts: z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(),
|
|
135
|
+
correspondenceFacts: z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(),
|
|
136
|
+
}).strict().optional(),
|
|
99
137
|
cases: z.array(caseSchema),
|
|
100
138
|
evidenceGaps: z.array(gapSchema).default([]),
|
|
101
139
|
coverageSummary: z
|
|
@@ -6,8 +6,13 @@ import { createWriteStream } from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { parseJacocoXml, } from "./backend-test-coverage-contract.js";
|
|
9
|
+
import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
|
-
|
|
11
|
+
// Canonical contract is three digits (BE-MODULE-001). Readers accept legacy/
|
|
12
|
+
// model-drift two-digit suffixes and canonicalize them in memory so one bad
|
|
13
|
+
// heading width cannot collapse the whole manifest/report to zero cases.
|
|
14
|
+
const CASE_ID = /\bBE-[A-Z0-9_-]+-\d{2,3}\b/g;
|
|
15
|
+
const CANONICAL_CASE_ID = /\bBE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\b/;
|
|
11
16
|
const AC_ID = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
|
|
12
17
|
const SECRET = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization)\s*[:=]\s*\S+/i;
|
|
13
18
|
const SECRET_ASSIGNMENT = /\b(password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization|cookie|set-cookie|credential)\b\s*[:=]\s*([^\r\n,;]+)/gi;
|
|
@@ -178,12 +183,27 @@ async function markdownFiles(root) {
|
|
|
178
183
|
.map((entry) => path.join(directory, entry.name))
|
|
179
184
|
.sort();
|
|
180
185
|
}
|
|
186
|
+
function canonicalBackendCaseId(value) {
|
|
187
|
+
const upper = value.toUpperCase();
|
|
188
|
+
const match = upper.match(/^(BE-[A-Z0-9_-]+)-(\d{2,3})$/);
|
|
189
|
+
if (!match)
|
|
190
|
+
return upper;
|
|
191
|
+
const modulePart = match[1]
|
|
192
|
+
.replaceAll("_", "-")
|
|
193
|
+
.replace(/-+/g, "-")
|
|
194
|
+
.replace(/-$/, "");
|
|
195
|
+
return `${modulePart}-${match[2].padStart(3, "0")}`;
|
|
196
|
+
}
|
|
197
|
+
function caseIdsInText(value) {
|
|
198
|
+
return unique((value.match(CASE_ID) ?? []).map(canonicalBackendCaseId));
|
|
199
|
+
}
|
|
181
200
|
function splitCases(markdown) {
|
|
182
201
|
const headings = [
|
|
183
|
-
...markdown.matchAll(/^##\s+(BE-[A-Z0-
|
|
202
|
+
...markdown.matchAll(/^##\s+(BE-[A-Z0-9_-]+-\d{2,3})\b.*$/gm),
|
|
184
203
|
];
|
|
185
204
|
return headings.map((match, index) => ({
|
|
186
|
-
id: match[1],
|
|
205
|
+
id: canonicalBackendCaseId(match[1]),
|
|
206
|
+
rawId: match[1],
|
|
187
207
|
body: markdown.slice(match.index, headings[index + 1]?.index ?? markdown.length),
|
|
188
208
|
}));
|
|
189
209
|
}
|
|
@@ -228,12 +248,20 @@ function extractSourceReferences(input) {
|
|
|
228
248
|
// rejects them instead of silently accepting a nearby bound prefix.
|
|
229
249
|
return unique([...boundMatches, ...citedPaths]);
|
|
230
250
|
}
|
|
251
|
+
function hasStructuredMarkdownTable(value, headerPattern) {
|
|
252
|
+
const rows = value.split(/\r?\n/).filter((line) => /^\s*\|.*\|\s*$/.test(line));
|
|
253
|
+
if (rows.length < 3 || !headerPattern.test(rows[0]))
|
|
254
|
+
return false;
|
|
255
|
+
return /^\s*\|(?:\s*:?-{3,}:?\s*\|)+\s*$/i.test(rows[1]) && rows.slice(2).some((row) => row.split("|").some((cell) => cell.trim().length > 0));
|
|
256
|
+
}
|
|
231
257
|
function hasNumberedListItem(value) {
|
|
232
258
|
return (/^\s*\d+[.)]\s+\S+/m.test(value) ||
|
|
233
|
-
/^\s*\|\s*\d+\s*\|\s*\S+/m.test(value)
|
|
259
|
+
/^\s*\|\s*\d+\s*\|\s*\S+/m.test(value) ||
|
|
260
|
+
hasStructuredMarkdownTable(value, /(?:步骤|操作|测试点|参数|step|action|operation|test point|parameter)/i));
|
|
234
261
|
}
|
|
235
262
|
function hasAssertableExpectedResult(value) {
|
|
236
|
-
return /^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value)
|
|
263
|
+
return (/^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value) ||
|
|
264
|
+
hasStructuredMarkdownTable(value, /(?:预期|期望|断言|状态|结果|expected|assert|status|result)/i));
|
|
237
265
|
}
|
|
238
266
|
function resolveBoundSourceReference(input) {
|
|
239
267
|
if (!input.sourceBinding) {
|
|
@@ -396,6 +424,9 @@ export async function validateBackendMarkdownCases(input) {
|
|
|
396
424
|
}
|
|
397
425
|
for (const testCase of cases) {
|
|
398
426
|
caseCount += 1;
|
|
427
|
+
if (!CANONICAL_CASE_ID.test(testCase.rawId)) {
|
|
428
|
+
findings.push(`${testCase.rawId} uses a non-canonical Case ID; use ${testCase.id} (hyphen-separated module, three-digit sequence)`);
|
|
429
|
+
}
|
|
399
430
|
const previous = seen.get(testCase.id);
|
|
400
431
|
if (previous) {
|
|
401
432
|
findings.push(`duplicate case id ${testCase.id}: ${previous} and ${relativeFile}`);
|
|
@@ -545,8 +576,10 @@ function testFunctionRegion(input) {
|
|
|
545
576
|
.join("\n");
|
|
546
577
|
}
|
|
547
578
|
function symbolCaseId(symbol) {
|
|
548
|
-
const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{3})(?:_|$)/i);
|
|
549
|
-
return match
|
|
579
|
+
const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{2,3})(?:_|$)/i);
|
|
580
|
+
return match
|
|
581
|
+
? canonicalBackendCaseId(match[1].replaceAll("_", "-"))
|
|
582
|
+
: undefined;
|
|
550
583
|
}
|
|
551
584
|
export async function validateBackendMarkdownTraceability(workspaceRoot) {
|
|
552
585
|
const files = await markdownFiles(workspaceRoot);
|
|
@@ -606,7 +639,7 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
|
|
|
606
639
|
functionIndex: match.index,
|
|
607
640
|
functionHeaderEnd: match.index + match[0].length,
|
|
608
641
|
});
|
|
609
|
-
const associatedIds = new Set(region
|
|
642
|
+
const associatedIds = new Set(caseIdsInText(region));
|
|
610
643
|
const fromSymbol = symbolCaseId(symbol);
|
|
611
644
|
if (fromSymbol)
|
|
612
645
|
associatedIds.add(fromSymbol);
|
|
@@ -711,7 +744,8 @@ export async function collectBackendTestMappedPytestScripts(workspaceRoot) {
|
|
|
711
744
|
function cleanCaseTitle(rawHeading, id) {
|
|
712
745
|
return rawHeading
|
|
713
746
|
.replace(/^##\s+/, "")
|
|
714
|
-
|
|
747
|
+
// Strip either canonical or compatible two-digit heading ID.
|
|
748
|
+
.replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "")
|
|
715
749
|
.trim() || id;
|
|
716
750
|
}
|
|
717
751
|
export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
|
|
@@ -755,7 +789,7 @@ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
|
|
|
755
789
|
for (const match of content.matchAll(/^(\s*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
|
|
756
790
|
const symbol = match[2];
|
|
757
791
|
const region = testFunctionRegion({ source: content, functionIndex: match.index, functionHeaderEnd: match.index + match[0].length });
|
|
758
|
-
const ids = new Set(region
|
|
792
|
+
const ids = new Set(caseIdsInText(region));
|
|
759
793
|
const fromSymbol = symbolCaseId(symbol);
|
|
760
794
|
if (fromSymbol)
|
|
761
795
|
ids.add(fromSymbol);
|
|
@@ -772,13 +806,13 @@ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
|
|
|
772
806
|
return [...catalog.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
773
807
|
}
|
|
774
808
|
function junitCaseId(name) {
|
|
775
|
-
return symbolCaseId(name) ?? name
|
|
809
|
+
return symbolCaseId(name) ?? caseIdsInText(name)[0];
|
|
776
810
|
}
|
|
777
811
|
/** Prefer function-name Case ID; fall back to first BE-* in free text (docstring/log). */
|
|
778
812
|
function extractCaseIdFromText(value) {
|
|
779
813
|
if (!value?.trim())
|
|
780
814
|
return undefined;
|
|
781
|
-
return junitCaseId(value) ?? value
|
|
815
|
+
return junitCaseId(value) ?? caseIdsInText(value)[0];
|
|
782
816
|
}
|
|
783
817
|
function resolveReportCaseId(result, catalog) {
|
|
784
818
|
const fromName = extractCaseIdFromText(result.name);
|
|
@@ -854,7 +888,10 @@ function reportOutputLines(value, prefix) {
|
|
|
854
888
|
const trimmed = line.startsWith(underscorePrefix)
|
|
855
889
|
? line.slice(underscorePrefix.length)
|
|
856
890
|
: line.slice(spacePrefix.length);
|
|
857
|
-
|
|
891
|
+
// Preserve a complete single-line JSON payload through parsing. Bounding
|
|
892
|
+
// happens after structural extraction; slicing here can corrupt valid JSON
|
|
893
|
+
// and make a large passed response appear as an empty body.
|
|
894
|
+
return redactBackendTestOutput(trimmed.trim());
|
|
858
895
|
});
|
|
859
896
|
}
|
|
860
897
|
/**
|
|
@@ -871,9 +908,16 @@ export function normalizeBackendTestHttpLogText(value) {
|
|
|
871
908
|
while (i < lines.length) {
|
|
872
909
|
const raw = lines[i];
|
|
873
910
|
const line = raw.trim();
|
|
874
|
-
//
|
|
875
|
-
|
|
876
|
-
|
|
911
|
+
// Canonical and compatible single-line forms. Normalize case, separator,
|
|
912
|
+
// and an optional colon so downstream parsing only consumes the canonical
|
|
913
|
+
// HTTP_REQUEST / HTTP_RESPONSE protocol. Examples accepted here include
|
|
914
|
+
// `HTTP_REQUEST {...}`, `HTTP REQUEST method=...`, and the generated-helper
|
|
915
|
+
// dialect `HTTP request: {...}`.
|
|
916
|
+
const protocolLine = line.match(/^HTTP(?:_|\s+)(REQUEST|RESPONSE)\b\s*:?\s*(.*)$/i);
|
|
917
|
+
if (protocolLine) {
|
|
918
|
+
const canonicalPrefix = `HTTP_${protocolLine[1].toUpperCase()}`;
|
|
919
|
+
const payload = protocolLine[2]?.trim() ?? "";
|
|
920
|
+
out.push(payload ? `${canonicalPrefix} ${payload}` : canonicalPrefix);
|
|
877
921
|
i += 1;
|
|
878
922
|
continue;
|
|
879
923
|
}
|
|
@@ -975,12 +1019,13 @@ function parseHttpLogPayload(line, kind) {
|
|
|
975
1019
|
const trimmed = line.trim();
|
|
976
1020
|
if (!trimmed)
|
|
977
1021
|
return {};
|
|
1022
|
+
let parsedJson;
|
|
978
1023
|
let kv = {};
|
|
979
1024
|
// Try JSON first (legacy HTTP_REQUEST {"method":...} shape).
|
|
980
1025
|
if (trimmed.startsWith("{")) {
|
|
981
1026
|
try {
|
|
982
|
-
|
|
983
|
-
for (const [key, value] of Object.entries(
|
|
1027
|
+
parsedJson = JSON.parse(trimmed);
|
|
1028
|
+
for (const [key, value] of Object.entries(parsedJson)) {
|
|
984
1029
|
kv[key.toLowerCase()] = typeof value === "string" ? value : safeStringify(value);
|
|
985
1030
|
}
|
|
986
1031
|
}
|
|
@@ -992,21 +1037,68 @@ function parseHttpLogPayload(line, kind) {
|
|
|
992
1037
|
kv = extractKvPairs(trimmed);
|
|
993
1038
|
}
|
|
994
1039
|
if (kind === "request") {
|
|
1040
|
+
const queryEnvelope = asHttpLogRecord(parsedJson?.query);
|
|
1041
|
+
const method = kv.method ?? httpLogScalar(queryEnvelope?.method);
|
|
1042
|
+
const url = kv.url ?? kv.path ?? httpLogScalar(queryEnvelope?.url) ?? httpLogScalar(queryEnvelope?.path);
|
|
1043
|
+
let body = kv.parameters ?? kv.payload ?? kv.data;
|
|
1044
|
+
if (!body && parsedJson) {
|
|
1045
|
+
const parameters = {};
|
|
1046
|
+
if (queryEnvelope) {
|
|
1047
|
+
const businessQuery = Object.fromEntries(Object.entries(queryEnvelope).filter(([key]) => !["method", "url", "path"].includes(key)));
|
|
1048
|
+
parameters.query = businessQuery;
|
|
1049
|
+
}
|
|
1050
|
+
else if (Object.hasOwn(parsedJson, "query")) {
|
|
1051
|
+
parameters.query = parsedJson.query;
|
|
1052
|
+
}
|
|
1053
|
+
for (const key of ["body", "json"]) {
|
|
1054
|
+
if (Object.hasOwn(parsedJson, key))
|
|
1055
|
+
parameters[key] = parsedJson[key];
|
|
1056
|
+
}
|
|
1057
|
+
if (Object.keys(parameters).length > 0)
|
|
1058
|
+
body = boundedHttpLogBody(parameters);
|
|
1059
|
+
}
|
|
995
1060
|
return {
|
|
996
|
-
method
|
|
997
|
-
url
|
|
998
|
-
body:
|
|
1061
|
+
method,
|
|
1062
|
+
url,
|
|
1063
|
+
body: boundedHttpLogBody(body ?? kv.body ?? kv.json ?? kv.query),
|
|
999
1064
|
};
|
|
1000
1065
|
}
|
|
1001
|
-
const
|
|
1066
|
+
const nestedJson = asHttpLogRecord(parsedJson?.json);
|
|
1067
|
+
const nestedTransport = !kv.status_code && !kv.status
|
|
1068
|
+
&& Boolean(nestedJson && (Object.hasOwn(nestedJson, "status_code") || Object.hasOwn(nestedJson, "body")));
|
|
1069
|
+
const status = kv.status_code ?? kv.status
|
|
1070
|
+
?? (nestedTransport ? httpLogScalar(nestedJson?.status_code ?? nestedJson?.status) : undefined);
|
|
1071
|
+
const body = kv.result
|
|
1072
|
+
?? (nestedTransport ? boundedHttpLogBody(nestedJson?.body ?? nestedJson) : kv.json)
|
|
1073
|
+
?? kv.body
|
|
1074
|
+
?? kv.text;
|
|
1002
1075
|
return {
|
|
1003
1076
|
method: kv.method,
|
|
1004
|
-
url: kv.url,
|
|
1077
|
+
url: kv.url ?? kv.path,
|
|
1005
1078
|
status,
|
|
1006
1079
|
statusText: kv.status_text ?? (status ? HTTP_STATUS_TEXT[status] : undefined),
|
|
1007
|
-
body:
|
|
1080
|
+
body: boundedHttpLogBody(body),
|
|
1008
1081
|
};
|
|
1009
1082
|
}
|
|
1083
|
+
function asHttpLogRecord(value) {
|
|
1084
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1085
|
+
? value
|
|
1086
|
+
: undefined;
|
|
1087
|
+
}
|
|
1088
|
+
function httpLogScalar(value) {
|
|
1089
|
+
return typeof value === "string" || typeof value === "number"
|
|
1090
|
+
? String(value)
|
|
1091
|
+
: undefined;
|
|
1092
|
+
}
|
|
1093
|
+
function boundedHttpLogBody(value) {
|
|
1094
|
+
if (value === undefined)
|
|
1095
|
+
return undefined;
|
|
1096
|
+
const text = typeof value === "string" ? value : safeStringify(value);
|
|
1097
|
+
const maxChars = 4000;
|
|
1098
|
+
return text.length <= maxChars
|
|
1099
|
+
? text
|
|
1100
|
+
: `${text.slice(0, maxChars)}\n… [truncated ${text.length - maxChars} chars]`;
|
|
1101
|
+
}
|
|
1010
1102
|
function extractKvPairs(value) {
|
|
1011
1103
|
const result = {};
|
|
1012
1104
|
// Split on top-level spaces but keep {...}/[...] payloads intact.
|
|
@@ -1175,15 +1267,62 @@ function renderHttpCallCard(call) {
|
|
|
1175
1267
|
: "";
|
|
1176
1268
|
return `<div style="border:1px solid #eef0f3;border-radius:10px;padding:12px 14px;margin-top:10px;background:#fcfdff">${header}${requestBlock}${responseBlock}</div>`;
|
|
1177
1269
|
}
|
|
1270
|
+
function analyzeBackendTestFailure(result) {
|
|
1271
|
+
const evidence = [result.message, result.details].filter(Boolean).join("\n");
|
|
1272
|
+
const statusPatterns = [
|
|
1273
|
+
{ pattern: /实际(?:\s*HTTP)?\s*状态码\s*[::]?\s*(\d{3})[\s\S]{0,80}?预期(?:\s*HTTP)?\s*状态码?\s*[::]?\s*(\d{3})/i, actualGroup: 1, expectedGroup: 2 },
|
|
1274
|
+
{ pattern: /预期(?:\s*HTTP)?\s*状态码\s*[::]?\s*(\d{3})[\s\S]{0,80}?实际(?:\s*HTTP)?\s*状态码?\s*[::]?\s*(\d{3})/i, actualGroup: 2, expectedGroup: 1 },
|
|
1275
|
+
{ pattern: /assert\s+(\d{3})\s*==\s*(\d{3})/i, actualGroup: 1, expectedGroup: 2 },
|
|
1276
|
+
{ pattern: /expected\s*[:=]?\s*(\d{3})[\s\S]{0,80}?(?:actual|received|got)\s*[:=]?\s*(\d{3})/i, actualGroup: 2, expectedGroup: 1 },
|
|
1277
|
+
];
|
|
1278
|
+
for (const candidate of statusPatterns) {
|
|
1279
|
+
const match = evidence.match(candidate.pattern);
|
|
1280
|
+
if (match) {
|
|
1281
|
+
const actual = match[candidate.actualGroup];
|
|
1282
|
+
const expected = match[candidate.expectedGroup];
|
|
1283
|
+
if (actual && expected) {
|
|
1284
|
+
return `HTTP 状态码不符合预期:期望 ${expected},实际 ${actual}。建议核对接口错误码契约、异常映射与测试前置数据。`;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
const contentTypeMatch = evidence.match(/assert\s+(['"])(.*?)\1\.startswith\(\s*(['"])application\/json\3\s*\)/i);
|
|
1289
|
+
if (contentTypeMatch) {
|
|
1290
|
+
const actual = contentTypeMatch[2];
|
|
1291
|
+
const actualLabel = actual ? `为 ${actual}` : "为空值";
|
|
1292
|
+
return `响应 Content-Type 不符合预期:期望以 application/json 开头,实际${actualLabel}。建议核对接口响应头设置。`;
|
|
1293
|
+
}
|
|
1294
|
+
const assertionEvidence = (result.details ?? "")
|
|
1295
|
+
.split(/\r?\n/)
|
|
1296
|
+
.map((line) => line.trim().replace(/^E\s+/, ""))
|
|
1297
|
+
.find((line) => /^assert\b/i.test(line));
|
|
1298
|
+
const rawMessage = /^\d+:\s*AssertionError\s*$/i.test(result.message?.trim() ?? "") && assertionEvidence
|
|
1299
|
+
? assertionEvidence
|
|
1300
|
+
: result.message || assertionEvidence || humanStatus(result.status);
|
|
1301
|
+
const message = rawMessage.replace(/\s+/g, " ").trim().slice(0, 240);
|
|
1302
|
+
if (result.status === "error") {
|
|
1303
|
+
return `测试执行发生错误:${message}。建议优先检查 fixture、依赖导入、环境连接与运行时异常。`;
|
|
1304
|
+
}
|
|
1305
|
+
return `断言未满足:${message}。建议结合失败详情及请求/响应证据核对接口行为与用例预期。`;
|
|
1306
|
+
}
|
|
1178
1307
|
export function renderBackendTestHtml(input) {
|
|
1179
1308
|
const failed = input.parsed.failed + input.parsed.errors > 0;
|
|
1180
1309
|
const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
|
|
1181
1310
|
const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
|
|
1182
1311
|
const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
|
|
1312
|
+
const caseCoverage = parseAdvisorySummary(input.caseCoverageSummary);
|
|
1183
1313
|
const traceability = parseAdvisorySummary(input.traceabilitySummary);
|
|
1314
|
+
const correspondence = parseAdvisorySummary(input.correspondenceSummary);
|
|
1184
1315
|
const failures = input.parsed.cases.filter((result) => result.status !== "passed");
|
|
1185
1316
|
const headColor = failed ? { fg: "#b42318", bg: "linear-gradient(135deg,#fef3f2 0,#fee4e2 100%)", border: "#fda29b", icon: "✗" } : { fg: "#067647", bg: "linear-gradient(135deg,#ecfdf3 0,#d1fadf 100%)", border: "#abefc6", icon: "✓" };
|
|
1186
|
-
const
|
|
1317
|
+
const orderedCases = input.parsed.cases
|
|
1318
|
+
.map((result, index) => ({ result, index }))
|
|
1319
|
+
.sort((left, right) => {
|
|
1320
|
+
const leftFailed = left.result.status === "failure" || left.result.status === "error" ? 0 : 1;
|
|
1321
|
+
const rightFailed = right.result.status === "failure" || right.result.status === "error" ? 0 : 1;
|
|
1322
|
+
return leftFailed - rightFailed || left.index - right.index;
|
|
1323
|
+
})
|
|
1324
|
+
.map(({ result }) => result);
|
|
1325
|
+
const caseCards = orderedCases.map((result) => {
|
|
1187
1326
|
const caseId = resolveReportCaseId(result, catalog);
|
|
1188
1327
|
const item = caseId === "未关联" ? undefined : catalog.get(caseId);
|
|
1189
1328
|
const io = requestResponseSummary(result);
|
|
@@ -1201,20 +1340,23 @@ export function renderBackendTestHtml(input) {
|
|
|
1201
1340
|
const metricCard = (label, value, valueColor = "#172033") => `<div style="padding:14px 16px;border:1px solid #e3e8ef;border-radius:12px;background:#fbfcfe"><span style="color:#667085;font-size:0.78rem">${escapeHtml(label)}</span><div style="font-size:22px;font-weight:700;color:${valueColor}">${value}</div></div>`;
|
|
1202
1341
|
const metrics = `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px;margin-top:14px">${metricCard("用例总数", input.parsed.tests)}${metricCard("通过", input.parsed.passed, "#067647")}${metricCard("失败", input.parsed.failed)}${metricCard("错误", input.parsed.errors)}${metricCard("跳过", input.parsed.skipped)}<div style="padding:14px 16px;border:1px solid #e3e8ef;border-radius:12px;background:#fbfcfe"><span style="color:#667085;font-size:0.78rem">通过率</span><div style="font-size:22px;font-weight:700;color:#067647">${(passRate * 100).toFixed(2)}%</div><span style="color:#667085;font-size:0.78rem">耗时 ${formatDuration(input.parsed.durationMs)}</span></div></div>`;
|
|
1203
1342
|
const summaryBanner = `<div style="display:flex;align-items:center;gap:16px;padding:16px 18px;border-radius:14px;background:${headColor.bg};border:1px solid ${headColor.border}"><div style="flex:0 0 auto;width:44px;height:44px;border-radius:999px;background:${headColor.fg};display:flex;align-items:center;justify-content:center;color:#fff;font-size:24px;font-weight:900;box-shadow:0 4px 12px ${headColor.fg}4d">${headColor.icon}</div><div><div style="color:${headColor.fg};font-size:1.05rem;font-weight:800;line-height:1.3">${failed ? "本轮测试未通过" : "本轮测试通过"}</div><div style="color:${headColor.fg};font-size:0.88rem;margin-top:2px">${failed ? `${input.parsed.failed + input.parsed.errors} 条用例失败或错误` : `${input.parsed.passed} 条用例全部执行成功`}</div></div></div>`;
|
|
1204
|
-
const qualityBlock = `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">质量校验</div><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-top:8px">${renderQualityCard("Markdown 用例校验", caseValidation)}${renderQualityCard("Markdown → pytest 追溯", traceability)}</div></div>`;
|
|
1343
|
+
const qualityBlock = `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">质量校验</div><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-top:8px">${renderQualityCard("Markdown 用例校验", caseValidation, input.caseValidationSummary)}${renderQualityCard("用例场景覆盖分析", caseCoverage, input.caseCoverageSummary)}${renderQualityCard("Markdown → pytest 追溯", traceability, input.traceabilitySummary)}${renderQualityCard("Markdown → pytest 一一对应", correspondence, input.correspondenceSummary)}</div></div>`;
|
|
1205
1344
|
const failureOverview = failures.length > 0
|
|
1206
1345
|
? `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">失败概览</div><div style="display:grid;gap:10px;margin-top:8px">${failures.map((result) => {
|
|
1207
1346
|
const caseId = resolveReportCaseId(result, catalog);
|
|
1208
1347
|
const c = statusColor(result.status);
|
|
1209
|
-
return `<div style="border-left:4px solid ${c.fg};background:${c.bg};padding:10px 12px;border-radius:8px"><div style="font-weight:600;color:#172033;font-size:0.88rem">${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</div><div style="color:${c.fg};font-size:0.82rem;margin-top:
|
|
1348
|
+
return `<div style="border-left:4px solid ${c.fg};background:${c.bg};padding:10px 12px;border-radius:8px"><div style="font-weight:600;color:#172033;font-size:0.88rem">${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</div><div style="color:${c.fg};font-size:0.82rem;margin-top:4px"><strong>原始失败:</strong>${escapeHtml(result.message || humanStatus(result.status))}</div><div style="color:#475467;font-size:0.82rem;margin-top:5px"><strong style="color:#17365d">原因分析:</strong>${escapeHtml(analyzeBackendTestFailure(result))}</div></div>`;
|
|
1210
1349
|
}).join("")}</div></div>`
|
|
1211
1350
|
: "";
|
|
1212
1351
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(input.title)}</title></head><body style="margin:0;background:linear-gradient(180deg,#eef2f7 0,#f7f9fc 40rem);color:#172033;font-family:system-ui,'Microsoft YaHei','PingFang SC',sans-serif;line-height:1.6"><div style="max-width:1000px;margin:0 auto;padding:32px 22px 56px"><div style="padding:8px 0 4px"><div style="color:#315f93;font-weight:700;letter-spacing:0.06em;font-size:0.76rem">后端自动化测试报告</div><h1 style="margin:6px 0 0;color:#17365d;font-size:24px;line-height:1.35">${escapeHtml(input.title)}</h1><p style="color:#667085;margin:8px 0 0;font-size:0.9rem">执行事实来自同一次 pytest 执行;用例默认折叠,点击任意用例可展开执行日志(接口、请求参数、响应结果)与检查点。</p></div><div style="background:#fff;border:1px solid #e3e8ef;border-radius:16px;padding:18px 20px;margin-top:16px;box-shadow:0 1px 2px rgba(16,24,40,0.04)">${summaryBanner}${metrics}</div><div style="background:#fff;border:1px solid #e3e8ef;border-radius:16px;padding:18px 20px;margin-top:16px;box-shadow:0 1px 2px rgba(16,24,40,0.04)">${qualityBlock}<div style="margin-top:12px;padding:10px 12px;background:#f6f8fa;border-radius:8px;font-size:0.82rem;color:#475467"><strong style="color:#667085">执行环境</strong>:${escapeHtml((input.environmentSummary || "不可用").split("\n").slice(0, 3).join(" ").slice(0, 200))}</div></div>${failureOverview}<div style="margin-top:18px"><div style="display:flex;align-items:baseline;justify-content:space-between;margin:0 4px 6px"><span style="color:#17365d;font-size:16px;font-weight:700">用例执行明细</span><span style="color:#98a2b3;font-size:0.8rem">共 ${input.parsed.cases.length} 条 · 点击展开</span></div>${caseCards || '<div style="padding:16px;border:1px dashed #e3e8ef;border-radius:12px;color:#667085;background:#fbfcfe;text-align:center">未发现可展示的用例。</div>'}</div></div></body></html>`;
|
|
1213
1352
|
}
|
|
1214
|
-
function renderQualityCard(title, summary) {
|
|
1353
|
+
function renderQualityCard(title, summary, rawSummary) {
|
|
1215
1354
|
const colors = summary.status === "PASS" ? { fg: "#067647", bg: "#ecfdf3" } : summary.status === "FAIL" ? { fg: "#b42318", bg: "#fef3f2" } : { fg: "#9a6700", bg: "#fffaeb" };
|
|
1216
1355
|
const label = summary.status === "Unavailable" ? "不可用" : summary.status;
|
|
1217
|
-
|
|
1356
|
+
const details = rawSummary?.trim()
|
|
1357
|
+
? `<details style="margin-top:9px;padding-top:8px;border-top:1px solid #e3e8ef"><summary style="cursor:pointer;color:#315f93;font-size:0.78rem;font-weight:700">查看详情 · ${escapeHtml(title)}详情</summary><pre style="white-space:pre-wrap;overflow-wrap:anywhere;background:#f6f8fa;color:#1f2937;padding:10px 12px;border-radius:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.74rem;margin:7px 0 0;border:1px solid #eceef2;line-height:1.55">${escapeHtml(rawSummary.trim())}</pre></details>`
|
|
1358
|
+
: "";
|
|
1359
|
+
return `<div style="padding:12px 14px;border:1px solid #e3e8ef;border-radius:10px;background:#fbfcfe"><div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px"><strong style="color:#17365d;font-size:0.88rem">${escapeHtml(title)}</strong><span style="display:inline-block;padding:2px 9px;border-radius:999px;font-weight:700;font-size:0.72rem;color:${colors.fg};background:${colors.bg}">${label}</span></div><div style="color:#667085;font-size:0.78rem">Findings:${summary.findings ?? "不可用"}</div><div style="color:#475467;font-size:0.8rem;margin-top:2px">${escapeHtml(summary.firstFinding)}</div>${details}</div>`;
|
|
1218
1360
|
}
|
|
1219
1361
|
/**
|
|
1220
1362
|
* Dump JaCoCo execution data over TCP from a JaCoCo tcpserver agent, convert
|
|
@@ -1308,6 +1450,118 @@ function dumpJacocoExec(host, port, destPath, connectTimeoutMs) {
|
|
|
1308
1450
|
socket.pipe(writeStream);
|
|
1309
1451
|
});
|
|
1310
1452
|
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Deterministically derive a Backend Test Case Manifest v1 from final
|
|
1455
|
+
* Markdown cases (testcase/md/**) and generated pytest scripts
|
|
1456
|
+
* (testcase/test_*.py). No model involvement: AC IDs come from the case
|
|
1457
|
+
* 验收标准/Acceptance Criteria section, automation status comes from whether
|
|
1458
|
+
* a pytest test function references the Case ID. The output feeds
|
|
1459
|
+
* computeCaseManifestCoverageSummary so the L-5 dashboard shows real
|
|
1460
|
+
* AC/automation coverage instead of unavailable.
|
|
1461
|
+
*/
|
|
1462
|
+
export async function deriveBackendTestCaseManifest(input) {
|
|
1463
|
+
const files = await markdownFiles(input.workspaceRoot);
|
|
1464
|
+
const manifestCases = [];
|
|
1465
|
+
for (const file of files) {
|
|
1466
|
+
if (path.basename(file).toLowerCase() === "readme.md")
|
|
1467
|
+
continue;
|
|
1468
|
+
const content = await readFile(file, "utf8");
|
|
1469
|
+
for (const testCase of splitCases(content)) {
|
|
1470
|
+
const acIds = unique(testCase.body.match(AC_ID) ?? []);
|
|
1471
|
+
if (acIds.length === 0)
|
|
1472
|
+
continue; // skip cases without AC binding
|
|
1473
|
+
const mappedScripts = extractMappedPytestScripts(testCase.body);
|
|
1474
|
+
const category = inferCaseCategory(testCase.body);
|
|
1475
|
+
let generated = false;
|
|
1476
|
+
let symbol;
|
|
1477
|
+
let scriptFile;
|
|
1478
|
+
for (const script of mappedScripts) {
|
|
1479
|
+
if (!isSafeBackendPytestScript(script, input.workspaceRoot))
|
|
1480
|
+
continue;
|
|
1481
|
+
const absolute = path.resolve(input.workspaceRoot, script);
|
|
1482
|
+
if (!(await exists(absolute)))
|
|
1483
|
+
continue;
|
|
1484
|
+
const source = await readFile(absolute, "utf8");
|
|
1485
|
+
const match = findCaseIdInPytest(source, testCase.id);
|
|
1486
|
+
if (match) {
|
|
1487
|
+
generated = true;
|
|
1488
|
+
symbol = match;
|
|
1489
|
+
scriptFile = script;
|
|
1490
|
+
break;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
manifestCases.push({
|
|
1494
|
+
caseId: testCase.id,
|
|
1495
|
+
acIds,
|
|
1496
|
+
title: inferCaseTitle(testCase.body, testCase.id),
|
|
1497
|
+
category,
|
|
1498
|
+
automationStatus: generated ? "generated" : "planned",
|
|
1499
|
+
...(scriptFile ? { file: scriptFile } : {}),
|
|
1500
|
+
...(symbol ? { symbol } : {}),
|
|
1501
|
+
...(generated ? {} : { gapReason: "pytest symbol not yet generated or not associated" }),
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
const manifest = backendTestCaseManifestSchema.parse({
|
|
1506
|
+
schemaVersion: 1,
|
|
1507
|
+
sourceBinding: {
|
|
1508
|
+
taskId: input.sourceBinding.taskId,
|
|
1509
|
+
requirementPath: input.sourceBinding.requirementPath,
|
|
1510
|
+
requirementSha256: input.sourceBinding.requirementSha256,
|
|
1511
|
+
referencePaths: input.sourceBinding.referencePaths,
|
|
1512
|
+
requirementIds: input.sourceBinding.requirementIds,
|
|
1513
|
+
},
|
|
1514
|
+
cases: manifestCases,
|
|
1515
|
+
evidenceGaps: [],
|
|
1516
|
+
coverageSummary: computeCaseManifestCoverageSummary({
|
|
1517
|
+
cases: manifestCases,
|
|
1518
|
+
evidenceGaps: [],
|
|
1519
|
+
sourceBinding: input.sourceBinding,
|
|
1520
|
+
}),
|
|
1521
|
+
});
|
|
1522
|
+
return manifest;
|
|
1523
|
+
}
|
|
1524
|
+
function inferCaseCategory(body) {
|
|
1525
|
+
const text = body.toLowerCase();
|
|
1526
|
+
if (/非法|无效|错误|不存在|越权|negative|invalid|unauthorized|forbidden|40[0-9]/.test(text))
|
|
1527
|
+
return "negative";
|
|
1528
|
+
if (/边界|boundary|edge|limit|max|min/.test(text))
|
|
1529
|
+
return "boundary";
|
|
1530
|
+
if (/鉴权|权限|登录|token|auth|permission|role/.test(text))
|
|
1531
|
+
return "auth";
|
|
1532
|
+
if (/状态|流转|state|transition|workflow/.test(text))
|
|
1533
|
+
return "state-transition";
|
|
1534
|
+
if (/超时|timeout/.test(text))
|
|
1535
|
+
return "timeout";
|
|
1536
|
+
if (/并发|concurren/.test(text))
|
|
1537
|
+
return "concurrency";
|
|
1538
|
+
return "positive";
|
|
1539
|
+
}
|
|
1540
|
+
function inferCaseTitle(body, caseId) {
|
|
1541
|
+
// ## BE-XXX-001|中文标题 → extract title after the delimiter
|
|
1542
|
+
const heading = body.split(/\r?\n/, 1)[0] ?? "";
|
|
1543
|
+
const delimiterMatch = heading.match(/[||]\s*(.+)$/);
|
|
1544
|
+
if (delimiterMatch)
|
|
1545
|
+
return delimiterMatch[1].trim();
|
|
1546
|
+
return caseId;
|
|
1547
|
+
}
|
|
1548
|
+
function findCaseIdInPytest(source, caseId) {
|
|
1549
|
+
for (const match of source.matchAll(/^(\s*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
|
|
1550
|
+
const symbol = match[2];
|
|
1551
|
+
const region = testFunctionRegion({
|
|
1552
|
+
source,
|
|
1553
|
+
functionIndex: match.index,
|
|
1554
|
+
functionHeaderEnd: match.index + match[0].length,
|
|
1555
|
+
});
|
|
1556
|
+
const ids = new Set(caseIdsInText(region));
|
|
1557
|
+
const fromSymbol = symbolCaseId(symbol);
|
|
1558
|
+
if (fromSymbol)
|
|
1559
|
+
ids.add(fromSymbol);
|
|
1560
|
+
if (ids.has(caseId))
|
|
1561
|
+
return symbol;
|
|
1562
|
+
}
|
|
1563
|
+
return undefined;
|
|
1564
|
+
}
|
|
1311
1565
|
function inferModuleScript(classname) {
|
|
1312
1566
|
// classname like "test_process_definition_list.py::TestX::test_a" → take the file stem.
|
|
1313
1567
|
const first = classname.split("::")[0] ?? classname;
|
|
@@ -1371,7 +1625,9 @@ export function renderBackendTestL5Dashboard(input) {
|
|
|
1371
1625
|
return `<div style="padding:13px 14px;border:1px solid #e4e9f1;border-radius:11px;background:#fbfcfe"><div style="display:flex;justify-content:space-between;align-items:center;gap:8px"><span style="color:#2a3c5a;font-size:12px;font-weight:700;font-family:ui-monospace,SFMono-Regular,Menlo,monospace">${escapeHtml(stat.script)}</span><span style="display:inline-flex;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:800;color:${badge.fg};background:${badge.bg}">${badge.t}</span></div><div style="height:6px;margin:9px 0 6px;background:#edf1f6;border-radius:99px;overflow:hidden;display:flex"><i style="display:block;height:100%;width:${passPct}%;background:#15815c"></i><i style="display:block;height:100%;width:${failPct}%;background:#d34661"></i></div></div>`;
|
|
1372
1626
|
}).join("");
|
|
1373
1627
|
const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
|
|
1628
|
+
const caseCoverage = parseAdvisorySummary(input.caseCoverageSummary);
|
|
1374
1629
|
const traceability = parseAdvisorySummary(input.traceabilitySummary);
|
|
1630
|
+
const correspondence = parseAdvisorySummary(input.correspondenceSummary);
|
|
1375
1631
|
const qRow = (name, status, extra = "") => {
|
|
1376
1632
|
const c = status === "PASS" ? { fg: "#15815c", bg: "#e7f7f0" } : status === "FAIL" ? { fg: "#d34661", bg: "#fff0f3" } : { fg: "#718097", bg: "#eef2f7" };
|
|
1377
1633
|
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 13px;border:1px solid #e4e9f1;border-radius:10px;background:#fbfcfe"><span style="color:#2a3c5a;font-size:13px;font-weight:650">${escapeHtml(name)}</span><span style="font-size:11px;font-weight:800;color:${c.fg};background:${c.bg};padding:2px 8px;border-radius:999px">${status === "Unavailable" ? "UNAVAILABLE" : status}${extra}</span></div>`;
|
|
@@ -1423,7 +1679,9 @@ export function renderBackendTestL5Dashboard(input) {
|
|
|
1423
1679
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
|
1424
1680
|
${qRow("环境硬门(Shell)", "PASS")}
|
|
1425
1681
|
${qRow("Markdown 用例校验(节点4)", caseValidation.status, caseValidation.findings !== undefined ? ` · ${caseValidation.findings}` : "")}
|
|
1682
|
+
${qRow("用例场景覆盖分析(节点4)", caseCoverage.status, caseCoverage.findings !== undefined ? ` · ${caseCoverage.findings}` : "")}
|
|
1426
1683
|
${qRow("Markdown → pytest 追溯(节点6)", traceability.status, traceability.findings !== undefined ? ` · ${traceability.findings}` : "")}
|
|
1684
|
+
${qRow("Markdown → pytest 一一对应(节点6)", correspondence.status, correspondence.findings !== undefined ? ` · ${correspondence.findings}` : "")}
|
|
1427
1685
|
${qRow("HTTP 请求/响应日志(节点6 advisory)", traceability.status)}
|
|
1428
1686
|
${qRow("Markdown 敏感信息", "PASS")}
|
|
1429
1687
|
${qRow("代码覆盖率", m.lineCoverage.status === "unavailable" && m.lineCoverage.reason !== null ? "Unavailable" : "PASS")}
|
|
@@ -1431,14 +1689,16 @@ export function renderBackendTestL5Dashboard(input) {
|
|
|
1431
1689
|
</section>
|
|
1432
1690
|
${failures ? `<section style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:20px"><div style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:12px"><h2 style="margin:0;color:#17365d;font-size:19px">缺陷记录</h2><span style="color:#718097;font-size:13px">${input.failures?.length ?? 0} 条 · 已关联用例与证据</span></div>${failures}</section>` : ""}
|
|
1433
1691
|
<section style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:20px">
|
|
1434
|
-
<p style="margin:0;color:#718097;font-size:12px">报告由 backend-test DAG 节点
|
|
1692
|
+
<p style="margin:0;color:#718097;font-size:12px">报告由 backend-test DAG 节点 8(execute-backend-pytest-and-html-report-shell)基于本轮真实执行产物确定性渲染。L-5 指标由 <code style="color:#17365d;font:11px ui-monospace,SFMono-Regular,Menlo,monospace">computeL5ReportMetrics</code> 机器计算。判定依据:pass=100%、AC=100%、automation≥90%、line≥80%、branch≥70%、skipped=0、无阻断级 Critical 风险。</p>
|
|
1435
1693
|
</section>
|
|
1436
1694
|
</div></body></html>`;
|
|
1437
1695
|
}
|
|
1438
1696
|
export function renderBackendTestFacts(input) {
|
|
1439
1697
|
const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
|
|
1440
1698
|
const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
|
|
1699
|
+
const caseCoverage = parseAdvisorySummary(input.caseCoverageSummary);
|
|
1441
1700
|
const traceability = parseAdvisorySummary(input.traceabilitySummary);
|
|
1701
|
+
const correspondence = parseAdvisorySummary(input.correspondenceSummary);
|
|
1442
1702
|
const catalog = new Map((input.cases ?? []).map((entry) => [entry.id, entry]));
|
|
1443
1703
|
return ([
|
|
1444
1704
|
"# 后端自动化测试执行报告",
|
|
@@ -1460,7 +1720,9 @@ export function renderBackendTestFacts(input) {
|
|
|
1460
1720
|
"| 校验项 | 状态 | Findings | 首要发现 |",
|
|
1461
1721
|
"|---|---|---:|---|",
|
|
1462
1722
|
`| Markdown 用例校验 | ${caseValidation.status} | ${caseValidation.findings ?? "不可用"} | ${caseValidation.firstFinding.replaceAll("|", "\\|")} |`,
|
|
1723
|
+
`| 用例场景覆盖分析 | ${caseCoverage.status} | ${caseCoverage.findings ?? "不可用"} | ${caseCoverage.firstFinding.replaceAll("|", "\\|")} |`,
|
|
1463
1724
|
`| Markdown → pytest 追溯 | ${traceability.status} | ${traceability.findings ?? "不可用"} | ${traceability.firstFinding.replaceAll("|", "\\|")} |`,
|
|
1725
|
+
`| Markdown → pytest 一一对应 | ${correspondence.status} | ${correspondence.findings ?? "不可用"} | ${correspondence.firstFinding.replaceAll("|", "\\|")} |`,
|
|
1464
1726
|
"",
|
|
1465
1727
|
"## 失败概览",
|
|
1466
1728
|
"",
|
|
@@ -1492,12 +1754,26 @@ export function renderBackendTestFacts(input) {
|
|
|
1492
1754
|
"</details>",
|
|
1493
1755
|
"",
|
|
1494
1756
|
"<details>",
|
|
1757
|
+
"<summary>用例场景覆盖分析原文</summary>",
|
|
1758
|
+
"",
|
|
1759
|
+
input.caseCoverageSummary?.trim() ?? "Unavailable",
|
|
1760
|
+
"",
|
|
1761
|
+
"</details>",
|
|
1762
|
+
"",
|
|
1763
|
+
"<details>",
|
|
1495
1764
|
"<summary>Markdown → pytest 追溯原文</summary>",
|
|
1496
1765
|
"",
|
|
1497
1766
|
input.traceabilitySummary?.trim() ?? "Unavailable",
|
|
1498
1767
|
"",
|
|
1499
1768
|
"</details>",
|
|
1500
1769
|
"",
|
|
1770
|
+
"<details>",
|
|
1771
|
+
"<summary>Markdown → pytest 一一对应原文</summary>",
|
|
1772
|
+
"",
|
|
1773
|
+
input.correspondenceSummary?.trim() ?? "Unavailable",
|
|
1774
|
+
"",
|
|
1775
|
+
"</details>",
|
|
1776
|
+
"",
|
|
1501
1777
|
"## 覆盖率证据",
|
|
1502
1778
|
"",
|
|
1503
1779
|
"- Code coverage: unavailable unless a separate validated coverage artifact exists.",
|