@tea-agent/loop-agent 0.20.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/dist/commands/init.js +7 -0
- package/dist/executors/dag-pi-executor.js +24 -0
- package/dist/executors/pi-executor.js +111 -36
- package/dist/executors/pi-sdk-executor.js +105 -29
- package/dist/executors/shell-executor.js +54 -11
- package/dist/worker/loop-agent/loop-agent-client.js +43 -9
- package/dist/worker/observability/read-model.js +7 -1
- package/dist/worker/observe/static/constants.js +5 -0
- package/dist/worker/observe/static/format-pool.js +22 -3
- package/dist/worker/observe/static/styles.css +32 -3
- package/dist/worker/observe/static/views/dag-inspector.js +2 -2
- package/dist/worker/run-task/run-task.js +16 -6
- package/dist/workflows/dag/backend-test-markdown-workflow.js +291 -97
- package/dist/workflows/dag/backend-test-result-contract.js +10 -4
- package/dist/workflows/dag/init-hybrid.js +27 -16
- package/dist/workflows/dag/lifecycle.js +60 -4
- package/dist/workflows/dag/liveness-policy.js +250 -0
- package/dist/workflows/dag/node-execution.js +49 -0
- package/dist/workflows/dag/runner.js +21 -1
- package/dist/workflows/dag/types.js +5 -0
- package/docs/README.md +5 -6
- package/docs/architecture/dag-execution.md +11 -0
- package/docs/architecture/facts-and-state.md +1 -0
- package/docs/architecture/worker-and-feature.md +10 -0
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
- package/docs/templates/backend-test-dag.json +15 -15
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +2 -0
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
|
@@ -4,6 +4,9 @@ import path from "node:path";
|
|
|
4
4
|
const CASE_ID = /\bBE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\b/g;
|
|
5
5
|
const AC_ID = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
|
|
6
6
|
const SECRET = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization)\s*[:=]\s*\S+/i;
|
|
7
|
+
const SECRET_ASSIGNMENT = /\b(password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization|cookie|set-cookie|credential)\b\s*[:=]\s*([^\r\n,;]+)/gi;
|
|
8
|
+
const SECRET_VALUE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:ghp|github_pat|xox[baprs]|AKIA|sk)[-_A-Za-z0-9]{12,}\b/i;
|
|
9
|
+
const PLACEHOLDER_SECRET_VALUE = /^(?:<[^>]+>|\$\{[^}]+\}|\*+|x+|redacted|masked|example(?:[-_].*)?|sample(?:[-_].*)?|dummy(?:[-_].*)?|test(?:[-_].*)?|your[-_].*)$/i;
|
|
7
10
|
const PLACEHOLDER = /\b(?:TODO|TBD|FIXME)\b|后续补充|暂不实现|接口正常|结果正确|按实际情况处理/i;
|
|
8
11
|
const CASE_SECTION_ALIASES = {
|
|
9
12
|
acceptanceCriteria: ["Acceptance Criteria", "验收标准"],
|
|
@@ -16,12 +19,9 @@ const CASE_SECTION_ALIASES = {
|
|
|
16
19
|
testPurpose: ["Test Purpose", "测试目的", "测试场景"],
|
|
17
20
|
};
|
|
18
21
|
const REQUIRED_CASE_SECTIONS = [
|
|
19
|
-
CASE_SECTION_ALIASES.acceptanceCriteria,
|
|
20
|
-
CASE_SECTION_ALIASES.sourceReferences,
|
|
21
22
|
CASE_SECTION_ALIASES.preconditions,
|
|
22
23
|
CASE_SECTION_ALIASES.steps,
|
|
23
24
|
CASE_SECTION_ALIASES.expectedResults,
|
|
24
|
-
CASE_SECTION_ALIASES.automationNotes,
|
|
25
25
|
];
|
|
26
26
|
async function exists(filePath) {
|
|
27
27
|
try {
|
|
@@ -217,7 +217,8 @@ function extractSourceReferences(input) {
|
|
|
217
217
|
return unique([...boundMatches, ...citedPaths]);
|
|
218
218
|
}
|
|
219
219
|
function hasNumberedListItem(value) {
|
|
220
|
-
return /^\s*\d+[.)]\s+\S+/m.test(value)
|
|
220
|
+
return (/^\s*\d+[.)]\s+\S+/m.test(value) ||
|
|
221
|
+
/^\s*\|\s*\d+\s*\|\s*\S+/m.test(value));
|
|
221
222
|
}
|
|
222
223
|
function hasAssertableExpectedResult(value) {
|
|
223
224
|
return /^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value);
|
|
@@ -303,94 +304,158 @@ function findUnsafeSwallowedException(source) {
|
|
|
303
304
|
}
|
|
304
305
|
return null;
|
|
305
306
|
}
|
|
307
|
+
function resolvesSharedLoggingHelper(source) {
|
|
308
|
+
return /\b(?:LoggedHttpClient|LoggedApiClient|LoggingHttpClient|HttpLoggingClient)\b/.test(source);
|
|
309
|
+
}
|
|
310
|
+
function httpLoggingFindings(relativeScript, source) {
|
|
311
|
+
if (resolvesSharedLoggingHelper(source))
|
|
312
|
+
return [];
|
|
313
|
+
const hasHttpRequest = /\b(?:requests\.(?:get|post|put|patch|delete|request)|httpx\.(?:get|post|put|patch|delete|request)|(?:client|session|api_client)\.(?:get|post|put|patch|delete|request))\s*\(/i.test(source);
|
|
314
|
+
if (!hasHttpRequest)
|
|
315
|
+
return [];
|
|
316
|
+
const findings = [];
|
|
317
|
+
const hasRequestLog = /(?:print|log(?:ger)?\.(?:debug|info|warning|error))\s*\([^\n]*(?:request|请求)[^\n]*(?:method|url|path|params|query|json|body|payload|参数)/i.test(source);
|
|
318
|
+
const hasResponseLog = /(?:print|log(?:ger)?\.(?:debug|info|warning|error))\s*\([^\n]*(?:response|响应)[^\n]*(?:status|status_code|body|json|text|result|结果)/i.test(source);
|
|
319
|
+
const hasSanitizer = /\b(?:sanitize|sanitise|redact|mask)[A-Za-z0-9_]*\s*\(|\b[A-Za-z0-9_]*(?:sanitize|sanitise|redact|mask)[A-Za-z0-9_]*\s*\(/i.test(source);
|
|
320
|
+
const hasBoundedOutput = /(?:MAX_[A-Z_]*(?:LOG|BODY|RESPONSE)|max_(?:log|body|response)|truncate|truncat|\[:\s*\d{3,}\s*\])/i.test(source);
|
|
321
|
+
if (!hasRequestLog)
|
|
322
|
+
findings.push(`request logging evidence is missing in mapped backend pytest: ${relativeScript}`);
|
|
323
|
+
if (!hasResponseLog)
|
|
324
|
+
findings.push(`response logging evidence is missing in mapped backend pytest: ${relativeScript}`);
|
|
325
|
+
if (!hasSanitizer)
|
|
326
|
+
findings.push(`recursive redaction evidence is missing in mapped backend pytest: ${relativeScript}`);
|
|
327
|
+
if (!hasBoundedOutput)
|
|
328
|
+
findings.push(`bounded log truncation evidence is missing in mapped backend pytest: ${relativeScript}`);
|
|
329
|
+
return findings;
|
|
330
|
+
}
|
|
331
|
+
function markdownSecretFindings(relativeFile, markdown) {
|
|
332
|
+
const findings = [];
|
|
333
|
+
if (SECRET_VALUE.test(markdown)) {
|
|
334
|
+
findings.push(`secret-shaped value detected in ${relativeFile}`);
|
|
335
|
+
}
|
|
336
|
+
for (const match of markdown.matchAll(SECRET_ASSIGNMENT)) {
|
|
337
|
+
const value = match[2]
|
|
338
|
+
.trim()
|
|
339
|
+
.replace(/^bearer\s+/i, "")
|
|
340
|
+
.replace(/^['"`]|['"`]$/g, "");
|
|
341
|
+
if (!PLACEHOLDER_SECRET_VALUE.test(value)) {
|
|
342
|
+
findings.push(`secret-shaped ${match[1].toLowerCase()} value detected in ${relativeFile}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return unique(findings);
|
|
346
|
+
}
|
|
347
|
+
export function hasBlockingBackendMarkdownSafetyFindings(report) {
|
|
348
|
+
return /^## Safety Status\s*\r?\n\s*BLOCKED\s*$/m.test(report);
|
|
349
|
+
}
|
|
306
350
|
export async function validateBackendMarkdownCases(input) {
|
|
307
|
-
const
|
|
351
|
+
const findings = [];
|
|
352
|
+
const safetyFindings = [];
|
|
353
|
+
let files = [];
|
|
354
|
+
try {
|
|
355
|
+
files = await markdownFiles(input.workspaceRoot);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
findings.push("missing testcase/md directory");
|
|
359
|
+
}
|
|
308
360
|
const readme = path.join(input.workspaceRoot, "testcase", "md", "README.md");
|
|
309
361
|
if (!(await exists(readme)))
|
|
310
|
-
|
|
362
|
+
findings.push("missing testcase/md/README.md");
|
|
311
363
|
const moduleFiles = files.filter((file) => path.basename(file).toLowerCase() !== "readme.md");
|
|
312
364
|
if (moduleFiles.length === 0)
|
|
313
|
-
|
|
365
|
+
findings.push("no testcase/md module case file found");
|
|
314
366
|
const seen = new Map();
|
|
315
367
|
const coveredAc = new Set();
|
|
316
368
|
let caseCount = 0;
|
|
317
369
|
for (const file of moduleFiles) {
|
|
370
|
+
const relativeFile = path.relative(input.workspaceRoot, file).replaceAll(path.sep, "/");
|
|
318
371
|
const markdown = await readFile(file, "utf8");
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
372
|
+
safetyFindings.push(...markdownSecretFindings(relativeFile, markdown));
|
|
373
|
+
if (!markdown.trim()) {
|
|
374
|
+
findings.push(`empty Markdown case file: ${relativeFile}`);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (PLACEHOLDER.test(markdown)) {
|
|
378
|
+
findings.push(`placeholder or non-assertable wording in ${relativeFile}`);
|
|
379
|
+
}
|
|
325
380
|
const cases = splitCases(markdown);
|
|
326
|
-
if (cases.length === 0)
|
|
327
|
-
|
|
381
|
+
if (cases.length === 0) {
|
|
382
|
+
findings.push(`no BE-<MODULE>-<NNN> case heading in ${relativeFile}`);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
328
385
|
for (const testCase of cases) {
|
|
329
386
|
caseCount += 1;
|
|
330
387
|
const previous = seen.get(testCase.id);
|
|
331
|
-
if (previous)
|
|
332
|
-
|
|
333
|
-
|
|
388
|
+
if (previous) {
|
|
389
|
+
findings.push(`duplicate case id ${testCase.id}: ${previous} and ${relativeFile}`);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
seen.set(testCase.id, relativeFile);
|
|
393
|
+
}
|
|
334
394
|
for (const sectionAliases of REQUIRED_CASE_SECTIONS) {
|
|
335
395
|
const sectionPattern = sectionAliases.map(escapeRegExp).join("|");
|
|
336
396
|
if (!new RegExp(`^###\\s+(?:${sectionPattern})\\s*$`, "mi").test(testCase.body)) {
|
|
337
|
-
|
|
397
|
+
findings.push(`${testCase.id} missing section: ${sectionAliases.join(" or ")}`);
|
|
338
398
|
}
|
|
339
399
|
}
|
|
340
400
|
const acIds = unique(testCase.body.match(AC_ID) ?? []);
|
|
341
401
|
acIds.forEach((id) => coveredAc.add(id));
|
|
342
|
-
const sourceSection = sectionBody(testCase.body, CASE_SECTION_ALIASES.sourceReferences);
|
|
343
|
-
const sourceRefs = extractSourceReferences({
|
|
344
|
-
sourceSection,
|
|
345
|
-
sourceBinding: input.sourceBinding,
|
|
346
|
-
});
|
|
347
|
-
if (sourceRefs.length === 0) {
|
|
348
|
-
throw new Error(`${testCase.id} has no task-relative source reference`);
|
|
349
|
-
}
|
|
350
|
-
for (const sourceRef of sourceRefs) {
|
|
351
|
-
if (/\.env(?:\.|$)|credential|secret/i.test(sourceRef)) {
|
|
352
|
-
throw new Error(`${testCase.id} references a forbidden source path: ${sourceRef}`);
|
|
353
|
-
}
|
|
354
|
-
const resolvedSourcePath = resolveBoundSourceReference({
|
|
355
|
-
workspaceRoot: input.workspaceRoot,
|
|
356
|
-
sourceRef,
|
|
357
|
-
sourceBinding: input.sourceBinding,
|
|
358
|
-
});
|
|
359
|
-
if (!(await exists(resolvedSourcePath))) {
|
|
360
|
-
throw new Error(`${testCase.id} references a missing source path: ${sourceRef}`);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
402
|
const steps = sectionBody(testCase.body, CASE_SECTION_ALIASES.steps);
|
|
364
403
|
const expected = sectionBody(testCase.body, CASE_SECTION_ALIASES.expectedResults);
|
|
365
|
-
if (!hasNumberedListItem(steps))
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
404
|
+
if (!hasNumberedListItem(steps)) {
|
|
405
|
+
findings.push(`${testCase.id} has no numbered executable step`);
|
|
406
|
+
}
|
|
407
|
+
if (!hasAssertableExpectedResult(expected)) {
|
|
408
|
+
findings.push(`${testCase.id} has no structured expected result`);
|
|
409
|
+
}
|
|
369
410
|
}
|
|
370
411
|
}
|
|
371
412
|
const missing = input.requiredRequirementIds.filter((id) => !coveredAc.has(id));
|
|
372
|
-
if (missing.length > 0)
|
|
373
|
-
|
|
413
|
+
if (missing.length > 0) {
|
|
414
|
+
findings.push(`required AC IDs are not covered by final Markdown cases: ${missing.join(", ")}`);
|
|
415
|
+
}
|
|
416
|
+
const status = findings.length === 0 ? "PASS" : "FAIL";
|
|
374
417
|
const report = [
|
|
375
418
|
"# Backend Markdown Case Validation",
|
|
376
419
|
"",
|
|
377
420
|
"## Status",
|
|
378
421
|
"",
|
|
379
|
-
|
|
422
|
+
status,
|
|
380
423
|
"",
|
|
381
424
|
`- Markdown files: ${files.length}`,
|
|
382
425
|
`- Module files: ${moduleFiles.length}`,
|
|
383
426
|
`- Cases: ${caseCount}`,
|
|
384
427
|
`- Covered AC IDs: ${[...coveredAc].sort().join(", ") || "none"}`,
|
|
385
428
|
`- Environment evidence present: ${input.environmentMarkdown.trim() ? "yes" : "no"}`,
|
|
386
|
-
|
|
387
|
-
"-
|
|
429
|
+
`- Findings: ${findings.length}`,
|
|
430
|
+
"- Required sections: Preconditions/前置条件, Steps/操作步骤, Expected Results/预期结果",
|
|
431
|
+
"- Source-reference validity check: not applied",
|
|
432
|
+
"- Markdown quality sensitive-information finding: not advisory; see Safety Status",
|
|
433
|
+
"",
|
|
434
|
+
"## Safety Status",
|
|
435
|
+
"",
|
|
436
|
+
safetyFindings.length > 0 ? "BLOCKED" : "PASS",
|
|
437
|
+
"",
|
|
438
|
+
...(safetyFindings.length > 0
|
|
439
|
+
? safetyFindings.map((finding) => `- ${finding}`)
|
|
440
|
+
: ["- No secret-shaped values detected."]),
|
|
441
|
+
"",
|
|
442
|
+
"## Findings",
|
|
443
|
+
"",
|
|
444
|
+
...(findings.length > 0 ? findings.map((finding) => `- ${finding}`) : ["- None"]),
|
|
388
445
|
];
|
|
389
446
|
return `${report.join("\n")}\n`;
|
|
390
447
|
}
|
|
391
448
|
function collectMarkdownCaseIds(markdown) {
|
|
392
449
|
return splitCases(markdown).map((testCase) => testCase.id);
|
|
393
450
|
}
|
|
451
|
+
function extractMappedPytestScripts(testCaseBody) {
|
|
452
|
+
const automation = sectionBody(testCaseBody, CASE_SECTION_ALIASES.automationNotes);
|
|
453
|
+
if (!automation.trim())
|
|
454
|
+
return [];
|
|
455
|
+
return unique([...automation.matchAll(/`?(testcase\/[A-Za-z0-9_./-]*test_[A-Za-z0-9_.-]*\.py)`?/gi)]
|
|
456
|
+
.map((match) => match[1].replaceAll("\\", "/"))
|
|
457
|
+
.filter((value) => !value.includes("..")));
|
|
458
|
+
}
|
|
394
459
|
function testFunctionRegion(input) {
|
|
395
460
|
const functionLineStart = input.source.lastIndexOf("\n", input.functionIndex - 1) + 1;
|
|
396
461
|
const functionLine = input.source.slice(functionLineStart, input.functionHeaderEnd);
|
|
@@ -432,35 +497,53 @@ function symbolCaseId(symbol) {
|
|
|
432
497
|
export async function validateBackendMarkdownTraceability(workspaceRoot) {
|
|
433
498
|
const files = await markdownFiles(workspaceRoot);
|
|
434
499
|
const markdownIds = new Set();
|
|
500
|
+
const expectedIdsByScript = new Map();
|
|
501
|
+
const findings = [];
|
|
435
502
|
for (const file of files) {
|
|
436
503
|
if (path.basename(file).toLowerCase() === "readme.md")
|
|
437
504
|
continue;
|
|
438
505
|
const content = await readFile(file, "utf8");
|
|
439
|
-
for (const
|
|
440
|
-
markdownIds.add(id);
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
506
|
+
for (const testCase of splitCases(content)) {
|
|
507
|
+
markdownIds.add(testCase.id);
|
|
508
|
+
const scripts = extractMappedPytestScripts(testCase.body);
|
|
509
|
+
if (scripts.length === 0) {
|
|
510
|
+
findings.push(`${testCase.id} has no mapped pytest script in Automation Notes/自动化映射`);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
for (const script of scripts) {
|
|
514
|
+
const bucket = expectedIdsByScript.get(script) ?? new Set();
|
|
515
|
+
bucket.add(testCase.id);
|
|
516
|
+
expectedIdsByScript.set(script, bucket);
|
|
517
|
+
}
|
|
451
518
|
}
|
|
452
519
|
}
|
|
453
|
-
await walk(testcaseRoot);
|
|
454
520
|
const mappings = new Map();
|
|
455
521
|
const automationIds = new Set();
|
|
456
|
-
|
|
457
|
-
|
|
522
|
+
let requestResponseLoggingFindingCount = 0;
|
|
523
|
+
for (const [relativeScript, expectedIds] of expectedIdsByScript) {
|
|
524
|
+
const absolute = path.resolve(workspaceRoot, relativeScript);
|
|
525
|
+
const relative = path.relative(workspaceRoot, absolute);
|
|
526
|
+
if (relative.startsWith("..") ||
|
|
527
|
+
path.isAbsolute(relative) ||
|
|
528
|
+
!relativeScript.startsWith("testcase/") ||
|
|
529
|
+
!/^test_.*\.py$/i.test(path.basename(relativeScript))) {
|
|
530
|
+
findings.push(`unsafe mapped pytest script: ${relativeScript}`);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (!(await exists(absolute))) {
|
|
534
|
+
findings.push(`mapped pytest script is missing: ${relativeScript}`);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const content = await readFile(absolute, "utf8");
|
|
538
|
+
const loggingFindings = httpLoggingFindings(relativeScript, content);
|
|
539
|
+
requestResponseLoggingFindingCount += loggingFindings.length;
|
|
540
|
+
findings.push(...loggingFindings);
|
|
458
541
|
if (/pytest\.(?:skip|xfail)\s*\(|@pytest\.mark\.(?:skip|xfail)\b/.test(content)) {
|
|
459
|
-
|
|
542
|
+
findings.push(`skip/xfail is forbidden in mapped backend pytest: ${relativeScript}`);
|
|
460
543
|
}
|
|
461
544
|
const unsafeSwallowedException = findUnsafeSwallowedException(content);
|
|
462
545
|
if (unsafeSwallowedException) {
|
|
463
|
-
|
|
546
|
+
findings.push(`swallowed exception is forbidden in mapped backend pytest: ${relativeScript} (${unsafeSwallowedException})`);
|
|
464
547
|
}
|
|
465
548
|
for (const match of content.matchAll(/^(\s*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
|
|
466
549
|
const symbol = match[2];
|
|
@@ -475,10 +558,10 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
|
|
|
475
558
|
associatedIds.add(fromSymbol);
|
|
476
559
|
for (const id of associatedIds) {
|
|
477
560
|
automationIds.add(id);
|
|
478
|
-
if (!
|
|
561
|
+
if (!expectedIds.has(id))
|
|
479
562
|
continue;
|
|
480
563
|
const bucket = mappings.get(id) ?? [];
|
|
481
|
-
bucket.push(`${
|
|
564
|
+
bucket.push(`${relativeScript}#${symbol}`);
|
|
482
565
|
mappings.set(id, bucket);
|
|
483
566
|
}
|
|
484
567
|
}
|
|
@@ -489,7 +572,7 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
|
|
|
489
572
|
.map(([id]) => id);
|
|
490
573
|
const extra = [...automationIds].filter((id) => !markdownIds.has(id));
|
|
491
574
|
if (missing.length > 0) {
|
|
492
|
-
|
|
575
|
+
findings.push(`Markdown Case IDs without pytest mapping: ${missing.join(", ")}`);
|
|
493
576
|
}
|
|
494
577
|
const mappingCount = [...mappings.values()].reduce((total, refs) => total + refs.length, 0);
|
|
495
578
|
return ([
|
|
@@ -497,17 +580,56 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
|
|
|
497
580
|
"",
|
|
498
581
|
"## Status",
|
|
499
582
|
"",
|
|
500
|
-
"PASS",
|
|
583
|
+
findings.length === 0 ? "PASS" : "FAIL",
|
|
501
584
|
"",
|
|
502
585
|
`- Markdown Case IDs: ${markdownIds.size}`,
|
|
503
586
|
`- Covered Markdown Case IDs: ${mappings.size}`,
|
|
504
587
|
`- Pytest mappings: ${mappingCount}`,
|
|
505
|
-
|
|
588
|
+
`- Scanned pytest scripts: ${expectedIdsByScript.size}`,
|
|
589
|
+
`- Missing mappings: ${missing.length}${missing.length ? ` (${missing.join(", ")})` : ""}`,
|
|
506
590
|
`- Multiple mappings: ${multiple.length}${multiple.length ? ` (${multiple.join(", ")})` : ""}`,
|
|
507
591
|
`- Extra automation Case IDs: ${extra.length}${extra.length ? ` (${extra.join(", ")})` : ""}`,
|
|
508
|
-
|
|
592
|
+
`- Request/response logging findings: ${requestResponseLoggingFindingCount}`,
|
|
593
|
+
"- Logging scope: mapped scripts with detected HTTP client calls; findings are advisory",
|
|
594
|
+
"- Scan scope: pytest scripts explicitly mapped by Markdown cases only",
|
|
595
|
+
"",
|
|
596
|
+
"## Findings",
|
|
597
|
+
"",
|
|
598
|
+
...(findings.length > 0 ? findings.map((finding) => `- ${finding}`) : ["- None"]),
|
|
509
599
|
].join("\n") + "\n");
|
|
510
600
|
}
|
|
601
|
+
export async function collectBackendTestMappedPytestScripts(workspaceRoot) {
|
|
602
|
+
const files = await markdownFiles(workspaceRoot);
|
|
603
|
+
const scripts = new Set();
|
|
604
|
+
for (const file of files) {
|
|
605
|
+
if (path.basename(file).toLowerCase() === "readme.md")
|
|
606
|
+
continue;
|
|
607
|
+
const markdown = await readFile(file, "utf8");
|
|
608
|
+
for (const testCase of splitCases(markdown)) {
|
|
609
|
+
for (const script of extractMappedPytestScripts(testCase.body))
|
|
610
|
+
scripts.add(script);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (scripts.size === 0) {
|
|
614
|
+
throw new Error("no pytest scripts are mapped by final Markdown cases");
|
|
615
|
+
}
|
|
616
|
+
const safeScripts = [];
|
|
617
|
+
for (const script of unique(scripts)) {
|
|
618
|
+
const absolute = path.resolve(workspaceRoot, script);
|
|
619
|
+
const relative = path.relative(workspaceRoot, absolute);
|
|
620
|
+
if (relative.startsWith("..") ||
|
|
621
|
+
path.isAbsolute(relative) ||
|
|
622
|
+
!script.startsWith("testcase/") ||
|
|
623
|
+
!/^test_.*\.py$/i.test(path.basename(script))) {
|
|
624
|
+
throw new Error(`unsafe mapped pytest script: ${script}`);
|
|
625
|
+
}
|
|
626
|
+
if (!(await exists(absolute))) {
|
|
627
|
+
throw new Error(`mapped pytest script is missing: ${script}`);
|
|
628
|
+
}
|
|
629
|
+
safeScripts.push(script);
|
|
630
|
+
}
|
|
631
|
+
return safeScripts;
|
|
632
|
+
}
|
|
511
633
|
function cleanCaseTitle(rawHeading, id) {
|
|
512
634
|
return rawHeading
|
|
513
635
|
.replace(/^##\s+/, "")
|
|
@@ -596,69 +718,141 @@ function escapeHtml(value) {
|
|
|
596
718
|
.replaceAll(">", ">")
|
|
597
719
|
.replaceAll('"', """);
|
|
598
720
|
}
|
|
721
|
+
function reportOutputLines(value, prefix) {
|
|
722
|
+
if (!value?.trim())
|
|
723
|
+
return [];
|
|
724
|
+
return value
|
|
725
|
+
.split(/\r?\n/)
|
|
726
|
+
.map((line) => line.trim())
|
|
727
|
+
.filter((line) => line.startsWith(prefix))
|
|
728
|
+
.map((line) => redactBackendTestOutput(line.slice(prefix.length).trim()).slice(0, 4000));
|
|
729
|
+
}
|
|
730
|
+
function requestResponseSummary(result) {
|
|
731
|
+
const combined = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
|
732
|
+
return {
|
|
733
|
+
requests: reportOutputLines(combined, "HTTP_REQUEST"),
|
|
734
|
+
responses: reportOutputLines(combined, "HTTP_RESPONSE"),
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
function reportDetails(title, values, emptyText) {
|
|
738
|
+
const body = values.length > 0
|
|
739
|
+
? values.map((value) => `<pre>${escapeHtml(value)}</pre>`).join("")
|
|
740
|
+
: `<p class="muted">${escapeHtml(emptyText)}</p>`;
|
|
741
|
+
return `<details class="io-details" open><summary>${escapeHtml(title)} <span>${values.length}</span></summary>${body}</details>`;
|
|
742
|
+
}
|
|
743
|
+
function parseAdvisorySummary(markdown) {
|
|
744
|
+
if (!markdown?.trim())
|
|
745
|
+
return { status: "Unavailable", findings: undefined, firstFinding: "未提供证据" };
|
|
746
|
+
const explicitStatus = markdown.match(/^## Status\s*\r?\n\s*(PASS|FAIL)\s*$/mi)?.[1]
|
|
747
|
+
?? markdown.trim().match(/^(PASS|FAIL)$/i)?.[1]?.toUpperCase();
|
|
748
|
+
const status = explicitStatus;
|
|
749
|
+
const findings = Number(markdown.match(/^- Findings:\s*(\d+)\s*$/mi)?.[1]);
|
|
750
|
+
const findingLines = markdown
|
|
751
|
+
.split(/\r?\n/)
|
|
752
|
+
.slice(markdown.split(/\r?\n/).findIndex((line) => /^## Findings\s*$/.test(line.trim())) + 1)
|
|
753
|
+
.map((line) => line.trim())
|
|
754
|
+
.filter((line) => /^-\s+/.test(line) && !/^- None$/i.test(line));
|
|
755
|
+
const resolvedFindings = Number.isFinite(findings) ? findings : findingLines.length;
|
|
756
|
+
return {
|
|
757
|
+
status: status ?? "Unavailable",
|
|
758
|
+
findings: resolvedFindings,
|
|
759
|
+
firstFinding: findingLines[0]?.replace(/^-\s+/, "") ?? "无",
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
function advisoryBadge(status) {
|
|
763
|
+
const className = status === "PASS" ? "passed" : status === "FAIL" ? "failure" : "skipped";
|
|
764
|
+
return `<span class="status ${className}">${status === "Unavailable" ? "不可用" : status}</span>`;
|
|
765
|
+
}
|
|
599
766
|
export function renderBackendTestHtml(input) {
|
|
600
767
|
const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
|
|
601
768
|
const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
|
|
602
769
|
const rows = input.parsed.cases.map((result) => {
|
|
603
770
|
const caseId = junitCaseId(result.name) ?? "未关联";
|
|
604
771
|
const item = catalog.get(caseId);
|
|
605
|
-
const
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
? "
|
|
609
|
-
|
|
610
|
-
return `<tr class="result-${result.status}"><td><code>${escapeHtml(caseId)}</code></td><td><strong>${escapeHtml(item?.title ?? result.name)}</strong><small>${escapeHtml(item?.scenario ?? "未从 Markdown 用例提取场景说明。")}</small></td><td><code>${escapeHtml(scriptPath)}</code><small>${escapeHtml(result.name)}</small></td><td><span class="status ${result.status}">${humanStatus(result.status)}</span></td><td>${formatDuration(result.durationMs)}</td><td>${failureCell}</td></tr>`;
|
|
772
|
+
const io = requestResponseSummary(result);
|
|
773
|
+
const failureDetail = result.status === "passed"
|
|
774
|
+
? ""
|
|
775
|
+
: `<div class="failure-detail"><strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${result.details ? `<details><summary>失败详情</summary><pre>${escapeHtml(result.details)}</pre></details>` : ""}</div>`;
|
|
776
|
+
return `<article class="case-card result-${result.status}"><div class="case-head"><div><span class="case-id">${escapeHtml(caseId)}</span><h3>${escapeHtml(item?.title ?? result.name)}</h3><p>${escapeHtml(item?.scenario ?? "未从 Markdown 用例提取场景说明。")}</p></div><div class="case-outcome"><span class="status ${result.status}">${humanStatus(result.status)}</span><span class="duration">${formatDuration(result.durationMs)}</span></div></div><div class="case-meta"><span>自动化用例</span><code>${escapeHtml(result.name)}</code></div><div class="io-grid">${reportDetails("接口请求参数", io.requests, "本次 JUnit 未捕获请求日志。")}${reportDetails("接口响应结果", io.responses, "本次 JUnit 未捕获响应日志。")}</div>${failureDetail}</article>`;
|
|
611
777
|
}).join("");
|
|
778
|
+
const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
|
|
779
|
+
const traceability = parseAdvisorySummary(input.traceabilitySummary);
|
|
780
|
+
const failures = input.parsed.cases.filter((result) => result.status !== "passed");
|
|
781
|
+
const failureOverview = failures.length > 0
|
|
782
|
+
? `<div class="failure-list">${failures.map((result) => {
|
|
783
|
+
const caseId = junitCaseId(result.name) ?? "未关联";
|
|
784
|
+
return `<article><div>${advisoryBadge(result.status === "skipped" ? "Unavailable" : "FAIL")}<strong>${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</strong></div><p>${escapeHtml(result.message || humanStatus(result.status))}</p></article>`;
|
|
785
|
+
}).join("")}</div>`
|
|
786
|
+
: '<div class="empty-state">本轮没有失败、错误或跳过用例。</div>';
|
|
612
787
|
const conclusion = input.parsed.failed + input.parsed.errors === 0
|
|
613
788
|
? `本轮测试通过:${input.parsed.passed} 条用例执行成功。`
|
|
614
789
|
: `本轮测试未通过:${input.parsed.failed + input.parsed.errors} 条用例失败或错误,请优先查看失败原因。`;
|
|
615
|
-
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><style>:root{color-scheme:light;--ink:#
|
|
790
|
+
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><style>:root{color-scheme:light;--ink:#182230;--muted:#667085;--line:#dce3ec;--panel:#f6f8fb;--brand:#163a63;--accent:#2e6da4;--pass:#067647;--pass-bg:#ecfdf3;--fail:#b42318;--fail-bg:#fef3f2;--skip:#9a6700;--skip-bg:#fffaeb;--shadow:0 18px 45px rgba(28,55,90,.09)}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top left,#dcecff 0,transparent 32rem),linear-gradient(180deg,#edf3fa 0,#f8fafc 34rem);color:var(--ink);font-family:Inter,system-ui,"Microsoft YaHei","PingFang SC",sans-serif;line-height:1.65}header,main{max-width:1280px;margin:auto}header{padding:3.5rem 2rem 1.75rem}main{padding:0 2rem 4rem}h1{margin:.3rem 0;color:var(--brand);font-size:clamp(2rem,4vw,3rem);letter-spacing:-.03em}h2{margin:0 0 1rem;color:var(--brand)}h3{margin:.35rem 0 .15rem;font-size:1.12rem}.eyebrow{color:var(--accent);font-weight:850;letter-spacing:.12em}.subtitle,.muted,.case-head p{color:var(--muted)}section{background:rgba(255,255,255,.94);border:1px solid rgba(220,227,236,.95);border-radius:1.15rem;padding:1.5rem;margin-top:1rem;box-shadow:var(--shadow)}.banner{padding:1rem 1.25rem;border-left:5px solid currentColor;border-radius:.8rem;background:${input.parsed.failed + input.parsed.errors === 0 ? "var(--pass-bg)" : "var(--fail-bg)"};color:${input.parsed.failed + input.parsed.errors === 0 ? "var(--pass)" : "var(--fail)"};font-weight:800}.cards{display:grid;grid-template-columns:repeat(6,minmax(7rem,1fr));gap:.75rem;margin-top:1rem}.card,.quality-card{padding:1rem;border:1px solid var(--line);border-radius:.85rem;background:white}.card span,.card small{display:block;color:var(--muted)}.card strong{font-size:1.55rem}.quality-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.quality-card header{display:flex;align-items:center;justify-content:space-between;padding:0;margin:0 0 .5rem}.quality-card p{margin:.25rem 0;color:var(--muted)}.status{display:inline-flex;align-items:center;padding:.18rem .62rem;border-radius:999px;font-weight:800;font-size:.86rem}.status.passed{color:var(--pass);background:var(--pass-bg)}.status.failure,.status.error{color:var(--fail);background:var(--fail-bg)}.status.skipped{color:var(--skip);background:var(--skip-bg)}.failure-list{display:grid;gap:.75rem}.failure-list article{border-left:4px solid var(--fail);background:var(--fail-bg);padding:.85rem 1rem;border-radius:.65rem}.empty-state{padding:1rem;border:1px dashed var(--line);border-radius:.75rem;color:var(--muted);background:var(--panel)}.case-list{display:grid;gap:1rem}.case-card{border:1px solid var(--line);border-radius:1rem;padding:1.15rem;background:linear-gradient(145deg,#fff,#fbfcfe);box-shadow:0 8px 24px rgba(28,55,90,.06)}.case-card.result-failure,.case-card.result-error{border-left:5px solid var(--fail)}.case-card.result-passed{border-left:5px solid var(--pass)}.case-head{display:flex;justify-content:space-between;gap:1rem;align-items:flex-start}.case-head p{margin:.2rem 0}.case-id{display:inline-block;color:var(--accent);font-weight:850;letter-spacing:.04em}.case-outcome{display:flex;align-items:center;gap:.75rem;white-space:nowrap}.duration{color:var(--muted);font-variant-numeric:tabular-nums}.case-meta{display:flex;gap:.75rem;align-items:center;margin:.85rem 0;padding:.65rem .8rem;border-radius:.7rem;background:var(--panel);color:var(--muted)}code{color:var(--brand);font-weight:700}.io-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem}.io-details{border:1px solid var(--line);border-radius:.75rem;padding:.7rem;background:white}.io-details summary{cursor:pointer;color:var(--brand);font-weight:800}.io-details summary span{float:right;color:var(--muted)}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:18rem;overflow:auto;background:#101828;color:#eef4ff;padding:.85rem;border-radius:.6rem;font-size:.82rem}.failure-detail{margin-top:.8rem;padding:.8rem;border-radius:.7rem;background:var(--fail-bg);color:var(--fail)}details summary{cursor:pointer}.subtitle{margin:.2rem 0 0}@media(max-width:900px){.cards{grid-template-columns:repeat(2,1fr)}.quality-grid,.io-grid{grid-template-columns:1fr}.case-head{display:block}.case-outcome{margin-top:.65rem}header,main{padding-left:1rem;padding-right:1rem}}</style></head><body><header><div class="eyebrow">后端自动化测试报告</div><h1>${escapeHtml(input.title)}</h1><p class="subtitle">测试结论、质量状态与每条接口用例的请求/响应事实均来自同一次 pytest 执行。</p></header><main><section><h2>测试结论</h2><div class="banner">${escapeHtml(conclusion)}</div><div class="cards"><div class="card"><span>用例总数</span><strong>${input.parsed.tests}</strong></div><div class="card"><span>通过</span><strong>${input.parsed.passed}</strong></div><div class="card"><span>失败</span><strong>${input.parsed.failed}</strong></div><div class="card"><span>错误</span><strong>${input.parsed.errors}</strong></div><div class="card"><span>跳过</span><strong>${input.parsed.skipped}</strong></div><div class="card"><span>通过率 / 总耗时</span><strong>${(passRate * 100).toFixed(2)}%</strong><small>${formatDuration(input.parsed.durationMs)}</small></div></div></section><section><h2>质量校验</h2><div class="quality-grid"><div class="quality-card"><header><strong>Markdown 用例校验</strong>${advisoryBadge(caseValidation.status)}</header><p>Findings:${caseValidation.findings ?? "不可用"}</p><p>${escapeHtml(caseValidation.firstFinding)}</p></div><div class="quality-card"><header><strong>Markdown → pytest 追溯</strong>${advisoryBadge(traceability.status)}</header><p>Findings:${traceability.findings ?? "不可用"}</p><p>${escapeHtml(traceability.firstFinding)}</p></div></div></section><section><h2>失败概览</h2>${failureOverview}</section><section><h2>用例执行结果</h2><div class="case-list">${rows || '<div class="empty-state">未发现可展示的 JUnit testcase。</div>'}</div></section></main></body></html>`;
|
|
616
791
|
}
|
|
617
792
|
export function renderBackendTestFacts(input) {
|
|
618
793
|
const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
|
|
794
|
+
const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
|
|
795
|
+
const traceability = parseAdvisorySummary(input.traceabilitySummary);
|
|
796
|
+
const catalog = new Map((input.cases ?? []).map((entry) => [entry.id, entry]));
|
|
619
797
|
return ([
|
|
620
|
-
"#
|
|
798
|
+
"# 后端自动化测试执行报告",
|
|
621
799
|
"",
|
|
622
|
-
|
|
800
|
+
`> ${input.pytestExitCode === 0 ? `本轮 ${input.parsed.passed}/${input.parsed.tests} 条用例通过。` : `本轮有 ${input.parsed.failed + input.parsed.errors} 条失败或错误,请查看失败概览。`}`,
|
|
801
|
+
"",
|
|
802
|
+
"## 测试概览",
|
|
803
|
+
"",
|
|
804
|
+
"| 状态 | 总数 | 通过 | 失败 | 错误 | 跳过 | 通过率 | 总耗时 |",
|
|
805
|
+
"|---|---:|---:|---:|---:|---:|---:|---:|",
|
|
806
|
+
`| ${input.pytestExitCode === 0 ? "通过" : "未通过"} | ${input.parsed.tests} | ${input.parsed.passed} | ${input.parsed.failed} | ${input.parsed.errors} | ${input.parsed.skipped} | ${(passRate * 100).toFixed(2)}% | ${formatDuration(input.parsed.durationMs)} |`,
|
|
623
807
|
"",
|
|
624
|
-
`- Status: ${input.pytestExitCode === 0 ? "passed" : "completed-with-failures"}`,
|
|
625
808
|
`- Pytest exit code: ${input.pytestExitCode}`,
|
|
626
|
-
`- Duration: ${input.parsed.durationMs ?? "unavailable"} ms`,
|
|
627
809
|
"- JUnit valid: yes",
|
|
628
810
|
"- HTML valid: yes",
|
|
629
811
|
"",
|
|
630
|
-
"##
|
|
812
|
+
"## 质量校验",
|
|
813
|
+
"",
|
|
814
|
+
"| 校验项 | 状态 | Findings | 首要发现 |",
|
|
815
|
+
"|---|---|---:|---|",
|
|
816
|
+
`| Markdown 用例校验 | ${caseValidation.status} | ${caseValidation.findings ?? "不可用"} | ${caseValidation.firstFinding.replaceAll("|", "\\|")} |`,
|
|
817
|
+
`| Markdown → pytest 追溯 | ${traceability.status} | ${traceability.findings ?? "不可用"} | ${traceability.firstFinding.replaceAll("|", "\\|")} |`,
|
|
631
818
|
"",
|
|
632
|
-
|
|
633
|
-
`- Passed: ${input.parsed.passed}`,
|
|
634
|
-
`- Failed: ${input.parsed.failed}`,
|
|
635
|
-
`- Errors: ${input.parsed.errors}`,
|
|
636
|
-
`- Skipped: ${input.parsed.skipped}`,
|
|
637
|
-
`- Pass rate: ${(passRate * 100).toFixed(2)}%`,
|
|
819
|
+
"## 失败概览",
|
|
638
820
|
"",
|
|
639
|
-
|
|
821
|
+
...(input.parsed.failures.length > 0
|
|
822
|
+
? input.parsed.failures.map((failure) => `- **${failure.name}** · ${failure.kind}: ${failure.message}`)
|
|
823
|
+
: ["- 无失败或错误用例。"]),
|
|
824
|
+
"",
|
|
825
|
+
"## 用例执行明细",
|
|
640
826
|
"",
|
|
641
827
|
"| 用例编号 | 用例名称 | 自动化脚本 | 测试函数 | 结果 | 耗时 | 失败原因 |",
|
|
642
828
|
"|---|---|---|---|---|---:|---|",
|
|
643
829
|
...input.parsed.cases.map((result) => {
|
|
644
830
|
const caseId = junitCaseId(result.name) ?? "未关联";
|
|
645
|
-
const item =
|
|
831
|
+
const item = catalog.get(caseId);
|
|
646
832
|
const script = item?.scriptPath ?? inferredScriptPath(result.classname);
|
|
647
833
|
return `| ${caseId} | ${item?.title ?? result.name} | \`${script}\` | \`${result.name}\` | ${humanStatus(result.status)} | ${formatDuration(result.durationMs)} | ${(result.message ?? "—").replaceAll("|", "\\|")} |`;
|
|
648
834
|
}),
|
|
649
835
|
"",
|
|
650
|
-
"##
|
|
836
|
+
"## 证据与完整校验原文",
|
|
651
837
|
"",
|
|
652
838
|
`- JUnit: ${input.junitRelativePath}`,
|
|
653
839
|
`- JUnit SHA-256: ${createHash("sha256").update(input.junitContent).digest("hex")}`,
|
|
654
840
|
`- HTML: ${input.htmlRelativePath}`,
|
|
655
841
|
`- HTML SHA-256: ${createHash("sha256").update(input.htmlContent).digest("hex")}`,
|
|
656
842
|
"",
|
|
657
|
-
"
|
|
843
|
+
"<details>",
|
|
844
|
+
"<summary>Markdown 用例校验原文</summary>",
|
|
658
845
|
"",
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
846
|
+
input.caseValidationSummary?.trim() ?? "Unavailable",
|
|
847
|
+
"",
|
|
848
|
+
"</details>",
|
|
849
|
+
"",
|
|
850
|
+
"<details>",
|
|
851
|
+
"<summary>Markdown → pytest 追溯原文</summary>",
|
|
852
|
+
"",
|
|
853
|
+
input.traceabilitySummary?.trim() ?? "Unavailable",
|
|
854
|
+
"",
|
|
855
|
+
"</details>",
|
|
662
856
|
"",
|
|
663
857
|
"## 成熟度证据",
|
|
664
858
|
"",
|
|
@@ -231,6 +231,12 @@ export function parseJunitXml(xml) {
|
|
|
231
231
|
const failureTag = body.match(/<failure\b([^>]*)>([\s\S]*?)<\/failure>|<failure\b([^>]*)\/>/i);
|
|
232
232
|
const errorTag = body.match(/<error\b([^>]*)>([\s\S]*?)<\/error>|<error\b([^>]*)\/>/i);
|
|
233
233
|
const skippedTag = /<skipped\b/i.test(body);
|
|
234
|
+
const caseStdout = body.match(/<system-out\b[^>]*>([\s\S]*?)<\/system-out>/i)?.[1];
|
|
235
|
+
const caseStderr = body.match(/<system-err\b[^>]*>([\s\S]*?)<\/system-err>/i)?.[1];
|
|
236
|
+
const capturedOutput = {
|
|
237
|
+
...(caseStdout ? { stdout: decodeXmlEntities(caseStdout).trim() } : {}),
|
|
238
|
+
...(caseStderr ? { stderr: decodeXmlEntities(caseStderr).trim() } : {}),
|
|
239
|
+
};
|
|
234
240
|
if (failureTag) {
|
|
235
241
|
caseFailed += 1;
|
|
236
242
|
const fAttrs = failureTag[1] ?? failureTag[3] ?? "";
|
|
@@ -243,7 +249,7 @@ export function parseJunitXml(xml) {
|
|
|
243
249
|
message: summary,
|
|
244
250
|
kind: "failure",
|
|
245
251
|
});
|
|
246
|
-
cases.push({ classname, name, durationMs: caseDurationMs, status: "failure", message: summary, details: fBody || summary });
|
|
252
|
+
cases.push({ classname, name, durationMs: caseDurationMs, status: "failure", message: summary, details: fBody || summary, ...capturedOutput });
|
|
247
253
|
}
|
|
248
254
|
else if (errorTag) {
|
|
249
255
|
caseErrors += 1;
|
|
@@ -257,16 +263,16 @@ export function parseJunitXml(xml) {
|
|
|
257
263
|
message: summary,
|
|
258
264
|
kind: "error",
|
|
259
265
|
});
|
|
260
|
-
cases.push({ classname, name, durationMs: caseDurationMs, status: "error", message: summary, details: eBody || summary });
|
|
266
|
+
cases.push({ classname, name, durationMs: caseDurationMs, status: "error", message: summary, details: eBody || summary, ...capturedOutput });
|
|
261
267
|
}
|
|
262
268
|
else if (skippedTag) {
|
|
263
269
|
caseSkipped += 1;
|
|
264
270
|
const skippedAttrs = body.match(/<skipped\b([^>]*)/i)?.[1] ?? "";
|
|
265
271
|
const message = attr(skippedAttrs, "message");
|
|
266
|
-
cases.push({ classname, name, durationMs: caseDurationMs, status: "skipped", ...(message ? { message } : {}) });
|
|
272
|
+
cases.push({ classname, name, durationMs: caseDurationMs, status: "skipped", ...(message ? { message } : {}), ...capturedOutput });
|
|
267
273
|
}
|
|
268
274
|
else {
|
|
269
|
-
cases.push({ classname, name, durationMs: caseDurationMs, status: "passed" });
|
|
275
|
+
cases.push({ classname, name, durationMs: caseDurationMs, status: "passed", ...capturedOutput });
|
|
270
276
|
}
|
|
271
277
|
match = caseRe.exec(trimmed);
|
|
272
278
|
}
|