@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/commands/init.js +7 -0
  3. package/dist/executors/dag-pi-executor.js +24 -0
  4. package/dist/executors/pi-executor.js +111 -36
  5. package/dist/executors/pi-sdk-executor.js +105 -29
  6. package/dist/executors/shell-executor.js +54 -11
  7. package/dist/worker/loop-agent/loop-agent-client.js +43 -9
  8. package/dist/worker/observability/read-model.js +7 -1
  9. package/dist/worker/observe/static/constants.js +5 -0
  10. package/dist/worker/observe/static/format-pool.js +22 -3
  11. package/dist/worker/observe/static/styles.css +32 -3
  12. package/dist/worker/observe/static/views/dag-inspector.js +2 -2
  13. package/dist/worker/run-task/run-task.js +16 -6
  14. package/dist/workflows/dag/backend-test-markdown-workflow.js +291 -97
  15. package/dist/workflows/dag/backend-test-result-contract.js +10 -4
  16. package/dist/workflows/dag/init-hybrid.js +27 -16
  17. package/dist/workflows/dag/lifecycle.js +60 -4
  18. package/dist/workflows/dag/liveness-policy.js +250 -0
  19. package/dist/workflows/dag/node-execution.js +49 -0
  20. package/dist/workflows/dag/runner.js +21 -1
  21. package/dist/workflows/dag/types.js +5 -0
  22. package/docs/README.md +5 -6
  23. package/docs/architecture/dag-execution.md +11 -0
  24. package/docs/architecture/facts-and-state.md +1 -0
  25. package/docs/architecture/worker-and-feature.md +10 -0
  26. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
  27. package/docs/templates/backend-test-dag.json +15 -15
  28. package/harness.json +1 -1
  29. package/package.json +1 -1
  30. package/skills/loop-agent/references/command-reference.md +2 -0
  31. 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 files = await markdownFiles(input.workspaceRoot);
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
- throw new Error("missing testcase/md/README.md");
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
- throw new Error("no testcase/md module case file found");
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
- if (!markdown.trim())
320
- throw new Error(`empty Markdown case file: ${path.relative(input.workspaceRoot, file)}`);
321
- if (SECRET.test(markdown))
322
- throw new Error(`secret-shaped content in ${path.relative(input.workspaceRoot, file)}`);
323
- if (PLACEHOLDER.test(markdown))
324
- throw new Error(`placeholder or non-assertable wording in ${path.relative(input.workspaceRoot, file)}`);
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
- throw new Error(`no BE-<MODULE>-<NNN> case heading in ${path.relative(input.workspaceRoot, file)}`);
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
- throw new Error(`duplicate case id ${testCase.id}: ${previous} and ${path.relative(input.workspaceRoot, file)}`);
333
- seen.set(testCase.id, path.relative(input.workspaceRoot, file).replaceAll(path.sep, "/"));
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
- throw new Error(`${testCase.id} missing section: ${sectionAliases.join(" or ")}`);
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
- throw new Error(`${testCase.id} has no numbered executable step`);
367
- if (!hasAssertableExpectedResult(expected))
368
- throw new Error(`${testCase.id} has no structured expected result`);
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
- throw new Error(`required AC IDs are not covered by final Markdown cases: ${missing.join(", ")}`);
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
- "PASS",
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
- "- Duplicate case IDs: 0",
387
- "- Secret-shaped findings: 0",
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 id of collectMarkdownCaseIds(content))
440
- markdownIds.add(id);
441
- }
442
- const testcaseRoot = path.join(workspaceRoot, "testcase");
443
- const pythonFiles = [];
444
- async function walk(directory) {
445
- for (const entry of await readdir(directory, { withFileTypes: true })) {
446
- const absolute = path.join(directory, entry.name);
447
- if (entry.isDirectory())
448
- await walk(absolute);
449
- else if (/^test_.*\.py$/i.test(entry.name))
450
- pythonFiles.push(absolute);
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
- for (const file of pythonFiles) {
457
- const content = await readFile(file, "utf8");
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
- throw new Error(`skip/xfail is forbidden in generated backend pytest: ${path.relative(workspaceRoot, file)}`);
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
- throw new Error(`swallowed exception is forbidden in generated backend pytest: ${path.relative(workspaceRoot, file)} (${unsafeSwallowedException})`);
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 (!markdownIds.has(id))
561
+ if (!expectedIds.has(id))
479
562
  continue;
480
563
  const bucket = mappings.get(id) ?? [];
481
- bucket.push(`${path.relative(workspaceRoot, file).replaceAll(path.sep, "/")}#${symbol}`);
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
- throw new Error(`traceability mismatch: missing=${missing.join(",")}; multiple=${multiple.join(",") || "none"}; extra=${extra.join(",") || "none"}`);
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
- "- Missing mappings: 0",
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
- "- skip/xfail findings: 0",
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(">", "&gt;")
597
719
  .replaceAll('"', "&quot;");
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 scriptPath = item?.scriptPath ?? inferredScriptPath(result.classname);
606
- const details = result.details || result.message || "";
607
- const failureCell = result.status === "passed"
608
- ? ""
609
- : `<strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${details ? `<details><summary>查看完整技术详情</summary><pre>${escapeHtml(details)}</pre></details>` : ""}`;
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:#172033;--muted:#667085;--line:#d9e2ec;--panel:#f7f9fc;--brand:#17365d;--pass:#067647;--pass-bg:#ecfdf3;--fail:#b42318;--fail-bg:#fef3f2;--skip:#9a6700;--skip-bg:#fffaeb}*{box-sizing:border-box}body{margin:0;background:#eef2f6;color:var(--ink);font-family:system-ui,"Microsoft YaHei","PingFang SC",sans-serif;line-height:1.6}header,main{max-width:1440px;margin:auto}header{padding:2.5rem 2rem 1.25rem}main{padding:0 2rem 3rem}h1{margin:.25rem 0;color:var(--brand)}h2{margin-top:2rem;color:var(--brand)}.eyebrow{color:var(--muted);font-weight:700}.banner{padding:1rem 1.25rem;border-radius:.75rem;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:700}.cards{display:grid;grid-template-columns:repeat(6,minmax(7rem,1fr));gap:.75rem;margin:1rem 0}.card{padding:1rem;border:1px solid var(--line);border-radius:.75rem;background:white}.card span,small{display:block;color:var(--muted)}.card strong{font-size:1.55rem}section{background:white;border:1px solid var(--line);border-radius:.9rem;padding:1.25rem;margin-top:1rem;box-shadow:0 8px 24px rgba(23,54,93,.04)}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:1100px}th,td{border-bottom:1px solid var(--line);padding:.75rem;text-align:left;vertical-align:top}th{background:var(--panel);position:sticky;top:0}.status{display:inline-block;padding:.15rem .55rem;border-radius:999px;font-weight:700}.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)}code{white-space:nowrap}pre{white-space:pre-wrap;max-height:22rem;overflow:auto;background:#101828;color:#f2f4f7;padding:1rem;border-radius:.5rem}details{margin-top:.5rem}summary{cursor:pointer;color:var(--brand);font-weight:700}.secondary{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.secondary pre{background:var(--panel);color:var(--ink)}@media(max-width:900px){.cards{grid-template-columns:repeat(2,1fr)}.secondary{grid-template-columns:1fr}header,main{padding-left:1rem;padding-right:1rem}}</style></head><body><header><div class="eyebrow">后端自动化测试报告</div><h1>${escapeHtml(input.title)}</h1><p>主要面向测试、研发和评审人员;执行事实来自同一次 pytest JUnit。</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="table-wrap"><table><thead><tr><th>用例编号</th><th>用例名称及测试场景</th><th>自动化脚本</th><th>结果</th><th>耗时</th><th>失败原因</th></tr></thead><tbody>${rows || '<tr><td colspan="6">未发现可展示的 JUnit testcase。</td></tr>'}</tbody></table></div></section><section><h2>执行证据</h2><div class="secondary"><details><summary>环境信息</summary><pre>${escapeHtml(input.environmentSummary.slice(0, 8000))}</pre></details><details><summary>用例追溯信息</summary><pre>${escapeHtml(input.traceabilitySummary.slice(0, 8000))}</pre></details></div></section></main></body></html>`;
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
- `- Total: ${input.parsed.tests}`,
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 = new Map((input.cases ?? []).map((entry) => [entry.id, entry])).get(caseId);
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
- ...(input.parsed.failures.length > 0
660
- ? input.parsed.failures.map((failure) => `- ${failure.name} [${failure.kind}]: ${failure.message}`)
661
- : ["- None"]),
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
  }