@tea-agent/loop-agent 0.20.1 → 0.22.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 (61) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/bin/agent-worker.js +0 -0
  3. package/dist/adapters/loop-agent.js +52 -0
  4. package/dist/commands/init.js +104 -0
  5. package/dist/executors/dag-pi-executor.js +26 -0
  6. package/dist/executors/pi-executor.js +111 -36
  7. package/dist/executors/pi-sdk-executor.js +105 -29
  8. package/dist/executors/shell-executor.js +215 -29
  9. package/dist/shared/openspec-spec.js +49 -0
  10. package/dist/worker/loop-agent/loop-agent-client.js +43 -9
  11. package/dist/worker/observability/read-model.js +28 -2
  12. package/dist/worker/observe/spec-evidence.js +12 -15
  13. package/dist/worker/observe/static/constants.js +5 -0
  14. package/dist/worker/observe/static/dag-helpers.js +22 -0
  15. package/dist/worker/observe/static/format-pool.js +22 -3
  16. package/dist/worker/observe/static/styles.css +32 -3
  17. package/dist/worker/observe/static/views/dag-inspector.js +2 -2
  18. package/dist/worker/observe/static/views/dag.js +5 -0
  19. package/dist/worker/run-task/run-task.js +16 -6
  20. package/dist/workflows/dag/backend-test-markdown-workflow.js +328 -97
  21. package/dist/workflows/dag/backend-test-result-contract.js +10 -4
  22. package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
  23. package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
  24. package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
  25. package/dist/workflows/dag/frontend-project-capability.js +11 -8
  26. package/dist/workflows/dag/frontend-repair.js +6 -4
  27. package/dist/workflows/dag/frontend-review-context.js +67 -0
  28. package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
  29. package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
  30. package/dist/workflows/dag/frontend-verification-trace.js +31 -1
  31. package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
  32. package/dist/workflows/dag/init-hybrid.js +370 -79
  33. package/dist/workflows/dag/lifecycle.js +60 -4
  34. package/dist/workflows/dag/liveness-policy.js +250 -0
  35. package/dist/workflows/dag/node-execution.js +49 -0
  36. package/dist/workflows/dag/runner.js +21 -1
  37. package/dist/workflows/dag/types.js +67 -1
  38. package/docs/README.md +5 -6
  39. package/docs/architecture/dag-execution.md +11 -0
  40. package/docs/architecture/facts-and-state.md +1 -0
  41. package/docs/architecture/worker-and-feature.md +10 -0
  42. package/docs/templates/agent-dag.schema.json +15 -5
  43. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
  44. package/docs/templates/backend-test-dag.json +15 -15
  45. package/docs/templates/frontend-implementation-contract.schema.json +4 -3
  46. package/docs/templates/frontend-test-case-checklist.md +6 -2
  47. package/docs/templates/frontend-test-dag.json +2 -2
  48. package/harness.json +1 -1
  49. package/package.json +1 -1
  50. package/skills/frontend-design-review/SKILL.md +12 -10
  51. package/skills/frontend-design-review/references/review-checklist.md +4 -4
  52. package/skills/frontend-implementation/SKILL.md +2 -2
  53. package/skills/frontend-implementation/references/code-standards.md +4 -3
  54. package/skills/frontend-implementation/references/design-spec.md +19 -14
  55. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  56. package/skills/frontend-review/SKILL.md +15 -28
  57. package/skills/frontend-review/references/review-findings.md +16 -18
  58. package/skills/frontend-verification/SKILL.md +16 -13
  59. package/skills/frontend-verification/references/verification-checklist.md +18 -30
  60. package/skills/loop-agent/references/command-reference.md +2 -0
  61. 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,178 @@ 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
+ }
766
+ function conclusionIcon(failed) {
767
+ return failed
768
+ ? `<svg class="conclusion-icon" viewBox="0 0 48 48" role="img" aria-label="测试未通过"><circle cx="24" cy="24" r="20"/><path d="M24 14v13"/><circle cx="24" cy="34" r="1.5" class="icon-dot"/></svg>`
769
+ : `<svg class="conclusion-icon" viewBox="0 0 48 48" role="img" aria-label="测试通过"><circle cx="24" cy="24" r="20"/><path d="m14 24 7 7 14-15"/></svg>`;
770
+ }
771
+ function reportDetailSection(title, content, open = false) {
772
+ return `<details class="report-detail"${open ? " open" : ""}><summary>${escapeHtml(title)}</summary><div class="report-detail-body">${content}</div></details>`;
773
+ }
599
774
  export function renderBackendTestHtml(input) {
775
+ const failed = input.parsed.failed + input.parsed.errors > 0;
776
+ const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
777
+ const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
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 rows = input.parsed.cases.map((result) => {
782
+ const caseId = junitCaseId(result.name) ?? "未关联";
783
+ const item = catalog.get(caseId);
784
+ const io = requestResponseSummary(result);
785
+ const failureDetails = result.status === "passed"
786
+ ? ""
787
+ : `<div class="failure-detail"><strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${result.details ? reportDetailSection("失败详情", `<pre>${escapeHtml(result.details)}</pre>`) : ""}</div>`;
788
+ 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>${failureDetails}</article>`;
789
+ }).join("");
790
+ const failureOverview = failures.length > 0
791
+ ? `<div class="failure-list">${failures.map((result) => {
792
+ const caseId = junitCaseId(result.name) ?? "未关联";
793
+ 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>`;
794
+ }).join("")}</div>`
795
+ : '<div class="empty-state">本轮没有失败、错误或跳过用例。</div>';
796
+ const conclusion = failed
797
+ ? `本轮测试未通过:${input.parsed.failed + input.parsed.errors} 条用例失败或错误。`
798
+ : `本轮测试通过:${input.parsed.passed} 条用例执行成功。`;
799
+ const executionOverview = `<div class="metric-grid"><div><span>用例总数</span><strong>${input.parsed.tests}</strong></div><div><span>通过</span><strong>${input.parsed.passed}</strong></div><div><span>失败 / 错误</span><strong>${input.parsed.failed} / ${input.parsed.errors}</strong></div><div><span>跳过</span><strong>${input.parsed.skipped}</strong></div><div><span>通过率</span><strong>${(passRate * 100).toFixed(2)}%</strong></div><div><span>总耗时</span><strong>${formatDuration(input.parsed.durationMs)}</strong></div></div>`;
800
+ const qualityDetails = `<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><div class="evidence-line"><span>执行环境</span>${escapeHtml(input.environmentSummary || "不可用")}</div>`;
801
+ 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,.report-detail{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)}.conclusion{display:flex;align-items:center;gap:1rem;padding:1rem 1.25rem;border-left:5px solid ${failed ? "var(--fail)" : "var(--pass)"};border-radius:.8rem;background:${failed ? "var(--fail-bg)" : "var(--pass-bg)"};color:${failed ? "var(--fail)" : "var(--pass)"}}.conclusion-icon{width:4rem;height:4rem;flex:0 0 4rem;display:block;overflow:visible}.conclusion-icon circle:first-child{fill:currentColor;fill-opacity:.12;stroke:currentColor;stroke-width:2}.conclusion-icon path{fill:none;stroke:currentColor;stroke-width:3.2;stroke-linecap:round;stroke-linejoin:round}.conclusion-icon .icon-dot{fill:currentColor;stroke:none}.conclusion-copy{min-width:0}.conclusion-copy strong{display:block;font-size:1.05rem}.conclusion-copy span{display:block;font-size:.9rem;font-weight:400}.metric-grid{display:grid;grid-template-columns:repeat(6,minmax(7rem,1fr));gap:.75rem;margin-top:1rem}.metric-grid>div{padding:1rem;border:1px solid var(--line);border-radius:.85rem;background:white}.metric-grid span,.metric-grid small{display:block;color:var(--muted);font-size:.88rem}.metric-grid strong{display:block;font-size:1.45rem;font-weight:700}.quality-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.quality-card{padding:1rem;border:1px solid var(--line);border-radius:.85rem;background:white}.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,.report-detail 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)}.report-detail{padding:0;overflow:hidden}.report-detail summary{list-style:none;padding:1rem 1.25rem}.report-detail summary::-webkit-details-marker{display:none}.report-detail summary::after{content:"+";float:right;font-size:1.2rem;font-weight:400;color:var(--muted)}.report-detail[open] summary::after{content:"−"}.report-detail-body{border-top:1px solid var(--line);padding:1.25rem}.evidence-line{display:flex;gap:.75rem;align-items:baseline;padding:.7rem .8rem;background:var(--panel);border-radius:.65rem;font-size:.9rem}.evidence-line span{color:var(--muted);min-width:8rem}.subtitle{margin:.2rem 0 0}@media(max-width:900px){.metric-grid{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}}@media(max-width:420px){.conclusion{align-items:flex-start}.conclusion-icon{width:3rem;height:3rem;flex-basis:3rem}.metric-grid{grid-template-columns:1fr 1fr}}</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="conclusion">${conclusionIcon(failed)}<div class="conclusion-copy"><strong>${escapeHtml(failed ? "测试未通过" : "测试通过")}</strong><span>${escapeHtml(conclusion)}</span></div></div>${executionOverview}</section>${reportDetailSection("质量校验", qualityDetails, true)}${reportDetailSection("失败概览", failureOverview, failures.length > 0)}${reportDetailSection("用例执行结果", `<div class="case-list">${rows || '<div class="empty-state">未发现可展示的 JUnit testcase。</div>'}</div>`, true)}</main></body></html>`;
802
+ }
803
+ function renderBackendTestHtmlLegacy(input) {
600
804
  const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
601
805
  const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
602
806
  const rows = input.parsed.cases.map((result) => {
603
807
  const caseId = junitCaseId(result.name) ?? "未关联";
604
808
  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>`;
809
+ const io = requestResponseSummary(result);
810
+ const failureDetail = result.status === "passed"
811
+ ? ""
812
+ : `<div class="failure-detail"><strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${result.details ? `<details><summary>失败详情</summary><pre>${escapeHtml(result.details)}</pre></details>` : ""}</div>`;
813
+ 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
814
  }).join("");
815
+ const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
816
+ const traceability = parseAdvisorySummary(input.traceabilitySummary);
817
+ const failures = input.parsed.cases.filter((result) => result.status !== "passed");
818
+ const failureOverview = failures.length > 0
819
+ ? `<div class="failure-list">${failures.map((result) => {
820
+ const caseId = junitCaseId(result.name) ?? "未关联";
821
+ 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>`;
822
+ }).join("")}</div>`
823
+ : '<div class="empty-state">本轮没有失败、错误或跳过用例。</div>';
612
824
  const conclusion = input.parsed.failed + input.parsed.errors === 0
613
825
  ? `本轮测试通过:${input.parsed.passed} 条用例执行成功。`
614
826
  : `本轮测试未通过:${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>`;
827
+ 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
828
  }
617
829
  export function renderBackendTestFacts(input) {
618
830
  const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
831
+ const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
832
+ const traceability = parseAdvisorySummary(input.traceabilitySummary);
833
+ const catalog = new Map((input.cases ?? []).map((entry) => [entry.id, entry]));
619
834
  return ([
620
- "# 后端测试执行事实",
835
+ "# 后端自动化测试执行报告",
836
+ "",
837
+ `> ${input.pytestExitCode === 0 ? `本轮 ${input.parsed.passed}/${input.parsed.tests} 条用例通过。` : `本轮有 ${input.parsed.failed + input.parsed.errors} 条失败或错误,请查看失败概览。`}`,
838
+ "",
839
+ "## 测试概览",
621
840
  "",
622
- "## 执行摘要",
841
+ "| 状态 | 总数 | 通过 | 失败 | 错误 | 跳过 | 通过率 | 总耗时 |",
842
+ "|---|---:|---:|---:|---:|---:|---:|---:|",
843
+ `| ${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
844
  "",
624
- `- Status: ${input.pytestExitCode === 0 ? "passed" : "completed-with-failures"}`,
625
845
  `- Pytest exit code: ${input.pytestExitCode}`,
626
- `- Duration: ${input.parsed.durationMs ?? "unavailable"} ms`,
627
846
  "- JUnit valid: yes",
628
847
  "- HTML valid: yes",
629
848
  "",
630
- "## 计数",
849
+ "## 质量校验",
631
850
  "",
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)}%`,
851
+ "| 校验项 | 状态 | Findings | 首要发现 |",
852
+ "|---|---|---:|---|",
853
+ `| Markdown 用例校验 | ${caseValidation.status} | ${caseValidation.findings ?? "不可用"} | ${caseValidation.firstFinding.replaceAll("|", "\\|")} |`,
854
+ `| Markdown → pytest 追溯 | ${traceability.status} | ${traceability.findings ?? "不可用"} | ${traceability.firstFinding.replaceAll("|", "\\|")} |`,
855
+ "",
856
+ "## 失败概览",
857
+ "",
858
+ ...(input.parsed.failures.length > 0
859
+ ? input.parsed.failures.map((failure) => `- **${failure.name}** · ${failure.kind}: ${failure.message}`)
860
+ : ["- 无失败或错误用例。"]),
638
861
  "",
639
- "## 逐条执行结果",
862
+ "## 用例执行明细",
640
863
  "",
641
864
  "| 用例编号 | 用例名称 | 自动化脚本 | 测试函数 | 结果 | 耗时 | 失败原因 |",
642
865
  "|---|---|---|---|---|---:|---|",
643
866
  ...input.parsed.cases.map((result) => {
644
867
  const caseId = junitCaseId(result.name) ?? "未关联";
645
- const item = new Map((input.cases ?? []).map((entry) => [entry.id, entry])).get(caseId);
868
+ const item = catalog.get(caseId);
646
869
  const script = item?.scriptPath ?? inferredScriptPath(result.classname);
647
870
  return `| ${caseId} | ${item?.title ?? result.name} | \`${script}\` | \`${result.name}\` | ${humanStatus(result.status)} | ${formatDuration(result.durationMs)} | ${(result.message ?? "—").replaceAll("|", "\\|")} |`;
648
871
  }),
649
872
  "",
650
- "## 证据",
873
+ "## 证据与完整校验原文",
651
874
  "",
652
875
  `- JUnit: ${input.junitRelativePath}`,
653
876
  `- JUnit SHA-256: ${createHash("sha256").update(input.junitContent).digest("hex")}`,
654
877
  `- HTML: ${input.htmlRelativePath}`,
655
878
  `- HTML SHA-256: ${createHash("sha256").update(input.htmlContent).digest("hex")}`,
656
879
  "",
657
- "## 失败用例",
880
+ "<details>",
881
+ "<summary>Markdown 用例校验原文</summary>",
658
882
  "",
659
- ...(input.parsed.failures.length > 0
660
- ? input.parsed.failures.map((failure) => `- ${failure.name} [${failure.kind}]: ${failure.message}`)
661
- : ["- None"]),
883
+ input.caseValidationSummary?.trim() ?? "Unavailable",
884
+ "",
885
+ "</details>",
886
+ "",
887
+ "<details>",
888
+ "<summary>Markdown → pytest 追溯原文</summary>",
889
+ "",
890
+ input.traceabilitySummary?.trim() ?? "Unavailable",
891
+ "",
892
+ "</details>",
662
893
  "",
663
894
  "## 成熟度证据",
664
895
  "",