qat-cli 0.2.98 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -596,7 +596,7 @@ declare function renderTemplate(type: TestType, context: TemplateContext): strin
596
596
  declare function generateTestFile(type: TestType, context: TemplateContext, outputDir: string): string;
597
597
 
598
598
  /**
599
- * 报告聚合服务 - 收集各运行器结果,生成完整HTML报告
599
+ * 报告聚合服务 - 收集各运行器结果,生成 Markdown 报告
600
600
  */
601
601
 
602
602
  /** 报告数据 */
@@ -618,20 +618,28 @@ interface ReportData {
618
618
  failed: number;
619
619
  skipped: number;
620
620
  }>;
621
+ /** 覆盖率汇总 */
622
+ coverage?: CoverageResult;
623
+ /** AI 审计报告 */
624
+ reviewSummary?: {
625
+ total: number;
626
+ approved: number;
627
+ avgScore: number;
628
+ };
621
629
  }
622
630
  /**
623
631
  * 聚合多个测试运行结果
624
632
  */
625
633
  declare function aggregateResults(results: TestRunResult[]): ReportData;
626
634
  /**
627
- * 生成完整 HTML 报告
628
- */
629
- declare function generateHTMLReport(data: ReportData): string;
630
- /**
631
- * 将报告写入磁盘
635
+ * 将报告写入磁盘(Markdown 格式)
632
636
  * @returns 报告文件路径
633
637
  */
634
638
  declare function writeReportToDisk(data: ReportData, outputDir: string): string;
639
+ /**
640
+ * 生成 HTML 报告(保留兼容,从 Markdown 转换为简易 HTML)
641
+ */
642
+ declare function generateHTMLReport(data: ReportData): string;
635
643
 
636
644
  /**
637
645
  * Vitest 运行器 - 通过子进程调用Vitest,解析输出收集结果
package/dist/index.d.ts CHANGED
@@ -596,7 +596,7 @@ declare function renderTemplate(type: TestType, context: TemplateContext): strin
596
596
  declare function generateTestFile(type: TestType, context: TemplateContext, outputDir: string): string;
597
597
 
598
598
  /**
599
- * 报告聚合服务 - 收集各运行器结果,生成完整HTML报告
599
+ * 报告聚合服务 - 收集各运行器结果,生成 Markdown 报告
600
600
  */
601
601
 
602
602
  /** 报告数据 */
@@ -618,20 +618,28 @@ interface ReportData {
618
618
  failed: number;
619
619
  skipped: number;
620
620
  }>;
621
+ /** 覆盖率汇总 */
622
+ coverage?: CoverageResult;
623
+ /** AI 审计报告 */
624
+ reviewSummary?: {
625
+ total: number;
626
+ approved: number;
627
+ avgScore: number;
628
+ };
621
629
  }
622
630
  /**
623
631
  * 聚合多个测试运行结果
624
632
  */
625
633
  declare function aggregateResults(results: TestRunResult[]): ReportData;
626
634
  /**
627
- * 生成完整 HTML 报告
628
- */
629
- declare function generateHTMLReport(data: ReportData): string;
630
- /**
631
- * 将报告写入磁盘
635
+ * 将报告写入磁盘(Markdown 格式)
632
636
  * @returns 报告文件路径
633
637
  */
634
638
  declare function writeReportToDisk(data: ReportData, outputDir: string): string;
639
+ /**
640
+ * 生成 HTML 报告(保留兼容,从 Markdown 转换为简易 HTML)
641
+ */
642
+ declare function generateHTMLReport(data: ReportData): string;
635
643
 
636
644
  /**
637
645
  * Vitest 运行器 - 通过子进程调用Vitest,解析输出收集结果
package/dist/index.js CHANGED
@@ -1374,6 +1374,7 @@ function aggregateResults(results) {
1374
1374
  pending: 0
1375
1375
  };
1376
1376
  const byType = {};
1377
+ let coverage;
1377
1378
  for (const result of results) {
1378
1379
  const typeKey = result.type;
1379
1380
  if (!byType[typeKey]) {
@@ -1397,6 +1398,16 @@ function aggregateResults(results) {
1397
1398
  }
1398
1399
  }
1399
1400
  }
1401
+ if (result.coverage) {
1402
+ if (!coverage) {
1403
+ coverage = { ...result.coverage };
1404
+ } else {
1405
+ coverage.lines = Math.max(coverage.lines, result.coverage.lines);
1406
+ coverage.statements = Math.max(coverage.statements, result.coverage.statements);
1407
+ coverage.functions = Math.max(coverage.functions, result.coverage.functions);
1408
+ coverage.branches = Math.max(coverage.branches, result.coverage.branches);
1409
+ }
1410
+ }
1400
1411
  }
1401
1412
  const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
1402
1413
  return {
@@ -1404,7 +1415,8 @@ function aggregateResults(results) {
1404
1415
  duration: totalDuration,
1405
1416
  results,
1406
1417
  summary,
1407
- byType
1418
+ byType,
1419
+ coverage: coverage?.lines ? coverage : void 0
1408
1420
  };
1409
1421
  }
1410
1422
  function formatDuration(ms) {
@@ -1424,56 +1436,149 @@ function formatTimestamp(ts) {
1424
1436
  second: "2-digit"
1425
1437
  });
1426
1438
  }
1427
- function renderSuiteHTML(suite) {
1428
- const statusClass = suite.status === "passed" ? "passed" : suite.status === "failed" ? "failed" : "skipped";
1429
- const testsHTML = suite.tests.map((test) => {
1430
- const testStatusClass = test.status;
1431
- const errorHTML = test.error ? `<div class="error-message"><strong>${escapeHTML(test.error.message)}</strong>${test.error.stack ? `
1432
- ${escapeHTML(test.error.stack)}` : ""}${test.error.expected && test.error.actual ? `
1439
+ function pct(value) {
1440
+ return `${(value * 100).toFixed(1)}%`;
1441
+ }
1442
+ function renderCoverageMD(coverage) {
1443
+ return `
1444
+ ### \u8986\u76D6\u7387
1433
1445
 
1434
- Expected: ${escapeHTML(test.error.expected)}
1435
- Actual: ${escapeHTML(test.error.actual)}` : ""}</div>` : "";
1436
- return `<div class="test-item">
1437
- <span class="status-dot ${testStatusClass}"></span>
1438
- <span class="test-name">${escapeHTML(test.name)}</span>
1439
- <span class="duration">${formatDuration(test.duration)}</span>
1440
- ${test.retries > 0 ? `<span class="retries">\u91CD\u8BD5 ${test.retries} \u6B21</span>` : ""}
1441
- </div>
1442
- ${errorHTML}`;
1443
- }).join("");
1444
- return `<div class="suite">
1445
- <div class="suite-header" onclick="this.parentElement.classList.toggle('collapsed')">
1446
- <div>
1447
- <span class="status-dot ${statusClass}"></span>
1448
- <strong>${escapeHTML(suite.name)}</strong>
1449
- <span class="suite-file">${escapeHTML(suite.file)}</span>
1450
- </div>
1451
- <div>
1452
- <span class="duration">${formatDuration(suite.duration)}</span>
1453
- <span class="toggle-icon">\u25BC</span>
1454
- </div>
1455
- </div>
1456
- <div class="suite-body">${testsHTML}</div>
1457
- </div>`;
1458
- }
1459
- function escapeHTML(str) {
1460
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1446
+ | \u6307\u6807 | \u8986\u76D6\u7387 | \u8FDB\u5EA6 |
1447
+ |------|--------|------|
1448
+ | \u8BED\u53E5 (Statements) | ${pct(coverage.statements)} | ${renderProgressBar(coverage.statements)} |
1449
+ | \u5206\u652F (Branches) | ${pct(coverage.branches)} | ${renderProgressBar(coverage.branches)} |
1450
+ | \u51FD\u6570 (Functions) | ${pct(coverage.functions)} | ${renderProgressBar(coverage.functions)} |
1451
+ | \u884C (Lines) | ${pct(coverage.lines)} | ${renderProgressBar(coverage.lines)} |
1452
+ `;
1453
+ }
1454
+ function renderProgressBar(value) {
1455
+ const filled = Math.round(value * 10);
1456
+ const empty = 10 - filled;
1457
+ return `${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}`;
1458
+ }
1459
+ var TYPE_LABELS = {
1460
+ unit: "\u5355\u5143\u6D4B\u8BD5",
1461
+ component: "\u7EC4\u4EF6\u6D4B\u8BD5",
1462
+ e2e: "E2E \u6D4B\u8BD5",
1463
+ api: "API \u6D4B\u8BD5",
1464
+ visual: "\u89C6\u89C9\u56DE\u5F52\u6D4B\u8BD5",
1465
+ performance: "\u6027\u80FD\u6D4B\u8BD5"
1466
+ };
1467
+ function generateMDReport(data) {
1468
+ const passRate = data.summary.total > 0 ? (data.summary.passed / data.summary.total * 100).toFixed(1) : "0";
1469
+ const rateIcon = parseFloat(passRate) >= 80 ? "\u2705" : parseFloat(passRate) >= 50 ? "\u26A0\uFE0F" : "\u274C";
1470
+ const lines = [];
1471
+ lines.push(`# QAT \u6D4B\u8BD5\u62A5\u544A`);
1472
+ lines.push("");
1473
+ lines.push(`> \u751F\u6210\u65F6\u95F4: ${formatTimestamp(data.timestamp)} | \u603B\u8017\u65F6: ${formatDuration(data.duration)}`);
1474
+ lines.push("");
1475
+ lines.push(`## \u603B\u89C8`);
1476
+ lines.push("");
1477
+ lines.push(`| \u6307\u6807 | \u6570\u503C |`);
1478
+ lines.push(`|------|------|`);
1479
+ lines.push(`| \u901A\u8FC7\u7387 | ${rateIcon} **${passRate}%** |`);
1480
+ lines.push(`| \u603B\u7528\u4F8B | ${data.summary.total} |`);
1481
+ lines.push(`| \u2705 \u901A\u8FC7 | ${data.summary.passed} |`);
1482
+ if (data.summary.failed > 0) lines.push(`| \u274C \u5931\u8D25 | ${data.summary.failed} |`);
1483
+ if (data.summary.skipped > 0) lines.push(`| \u23ED\uFE0F \u8DF3\u8FC7 | ${data.summary.skipped} |`);
1484
+ if (data.summary.pending > 0) lines.push(`| \u23F3 \u5F85\u5B9A | ${data.summary.pending} |`);
1485
+ lines.push(`| \u23F1\uFE0F \u8017\u65F6 | ${formatDuration(data.duration)} |`);
1486
+ lines.push("");
1487
+ if (Object.keys(data.byType).length > 0) {
1488
+ lines.push(`## \u6309\u7C7B\u578B\u7EDF\u8BA1`);
1489
+ lines.push("");
1490
+ lines.push(`| \u7C7B\u578B | \u901A\u8FC7 | \u5931\u8D25 | \u8DF3\u8FC7 | \u603B\u8BA1 | \u901A\u8FC7\u7387 |`);
1491
+ lines.push(`|------|------|------|------|------|--------|`);
1492
+ for (const [type, stats] of Object.entries(data.byType)) {
1493
+ const label = TYPE_LABELS[type] || type;
1494
+ const rate = stats.total > 0 ? (stats.passed / stats.total * 100).toFixed(0) + "%" : "-";
1495
+ lines.push(`| ${label} | ${stats.passed} | ${stats.failed} | ${stats.skipped} | ${stats.total} | ${rate} |`);
1496
+ }
1497
+ lines.push("");
1498
+ }
1499
+ if (data.coverage) {
1500
+ lines.push(renderCoverageMD(data.coverage));
1501
+ lines.push("");
1502
+ }
1503
+ lines.push(`## \u6D4B\u8BD5\u8BE6\u60C5`);
1504
+ lines.push("");
1505
+ for (const result of data.results) {
1506
+ const typeLabel = TYPE_LABELS[result.type] || result.type;
1507
+ const statusIcon = result.status === "passed" ? "\u2705" : result.status === "failed" ? "\u274C" : "\u26A0\uFE0F";
1508
+ lines.push(`### ${statusIcon} ${typeLabel}`);
1509
+ lines.push("");
1510
+ if (result.suites.length === 0) {
1511
+ lines.push(`*\u65E0\u6D4B\u8BD5\u7ED3\u679C*`);
1512
+ lines.push("");
1513
+ continue;
1514
+ }
1515
+ for (const suite of result.suites) {
1516
+ const suiteIcon = suite.status === "passed" ? "\u2705" : suite.status === "failed" ? "\u274C" : "\u26A0\uFE0F";
1517
+ lines.push(`#### ${suiteIcon} ${suite.name}`);
1518
+ lines.push("");
1519
+ lines.push(`- \u6587\u4EF6: \`${suite.file}\``);
1520
+ lines.push(`- \u8017\u65F6: ${formatDuration(suite.duration)}`);
1521
+ lines.push("");
1522
+ if (suite.tests.length > 0) {
1523
+ lines.push(`| \u72B6\u6001 | \u6D4B\u8BD5\u540D\u79F0 | \u8017\u65F6 |`);
1524
+ lines.push(`|------|----------|------|`);
1525
+ for (const test of suite.tests) {
1526
+ const testIcon = test.status === "passed" ? "\u2705" : test.status === "failed" ? "\u274C" : test.status === "skipped" ? "\u23ED\uFE0F" : "\u23F3";
1527
+ const name = test.error ? `**${test.name}**` : test.name;
1528
+ lines.push(`| ${testIcon} | ${name} | ${formatDuration(test.duration)} |`);
1529
+ }
1530
+ lines.push("");
1531
+ }
1532
+ const failedTests = suite.tests.filter((t) => t.status === "failed" && t.error);
1533
+ if (failedTests.length > 0) {
1534
+ lines.push(`<details>`);
1535
+ lines.push(`<summary>\u274C \u5931\u8D25\u8BE6\u60C5 (${failedTests.length})</summary>`);
1536
+ lines.push("");
1537
+ for (const test of failedTests) {
1538
+ lines.push(`**${test.name}**`);
1539
+ lines.push("```");
1540
+ lines.push(test.error.message);
1541
+ if (test.error.stack) {
1542
+ lines.push(test.error.stack);
1543
+ }
1544
+ if (test.error.expected && test.error.actual) {
1545
+ lines.push(`Expected: ${test.error.expected}`);
1546
+ lines.push(`Actual: ${test.error.actual}`);
1547
+ }
1548
+ lines.push("```");
1549
+ lines.push("");
1550
+ }
1551
+ lines.push(`</details>`);
1552
+ lines.push("");
1553
+ }
1554
+ }
1555
+ }
1556
+ lines.push("---");
1557
+ lines.push("");
1558
+ lines.push(`*\u7531 QAT \u81EA\u52A8\u5316\u6D4B\u8BD5\u5DE5\u5177\u751F\u6210 | ${formatTimestamp(data.timestamp)}*`);
1559
+ return lines.join("\n");
1560
+ }
1561
+ function writeReportToDisk(data, outputDir) {
1562
+ const md = generateMDReport(data);
1563
+ const dir = path4.resolve(outputDir);
1564
+ if (!fs5.existsSync(dir)) {
1565
+ fs5.mkdirSync(dir, { recursive: true });
1566
+ }
1567
+ const mdPath = path4.join(dir, "report.md");
1568
+ fs5.writeFileSync(mdPath, md, "utf-8");
1569
+ const jsonPath = path4.join(dir, "report.json");
1570
+ fs5.writeFileSync(jsonPath, JSON.stringify(data, null, 2), "utf-8");
1571
+ return mdPath;
1461
1572
  }
1462
1573
  function generateHTMLReport(data) {
1463
1574
  const passRate = data.summary.total > 0 ? (data.summary.passed / data.summary.total * 100).toFixed(1) : "0";
1464
- const suitesHTML = data.results.flatMap((r) => r.suites).map(renderSuiteHTML).join("\n");
1465
- const byTypeHTML = Object.entries(data.byType).map(([type, stats]) => {
1466
- const rate = stats.total > 0 ? (stats.passed / stats.total * 100).toFixed(0) : "0";
1467
- return `<div class="type-card">
1468
- <div class="type-name">${type}</div>
1469
- <div class="type-stats">
1470
- <span class="passed">${stats.passed} \u901A\u8FC7</span>
1471
- <span class="failed">${stats.failed} \u5931\u8D25</span>
1472
- <span class="skipped">${stats.skipped} \u8DF3\u8FC7</span>
1473
- </div>
1474
- <div class="type-rate">${rate}%</div>
1475
- </div>`;
1476
- }).join("\n");
1575
+ const md = generateMDReport(data);
1576
+ const html = md.replace(/^### (.+)$/gm, "<h3>$1</h3>").replace(/^## (.+)$/gm, "<h2>$1</h2>").replace(/^# (.+)$/gm, "<h1>$1</h1>").replace(/^> (.+)$/gm, "<blockquote>$1</blockquote>").replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/^---$/gm, "<hr>").replace(/\|(.+)\|/g, (match) => {
1577
+ const cells = match.split("|").filter(Boolean).map((c) => c.trim());
1578
+ if (cells.every((c) => c.startsWith("-") || c === "")) return "";
1579
+ const tds = cells.map((c) => `<td>${c}</td>`).join("");
1580
+ return `<tr>${tds}</tr>`;
1581
+ }).replace(/<tr>/g, "<table><tr>").replace(/<\/tr>(?!\s*<table>)/g, "</tr></table>").replace(/<\/table>\s*<table>/g, "").replace(/^✅/gm, '<span style="color:#22c55e">\u2705</span>').replace(/^❌/gm, '<span style="color:#ef4444">\u274C</span>').replace(/^⚠️/gm, '<span style="color:#f59e0b">\u26A0\uFE0F</span>');
1477
1582
  return `<!DOCTYPE html>
1478
1583
  <html lang="zh-CN">
1479
1584
  <head>
@@ -1481,152 +1586,39 @@ function generateHTMLReport(data) {
1481
1586
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1482
1587
  <title>QAT \u6D4B\u8BD5\u62A5\u544A - ${formatTimestamp(data.timestamp)}</title>
1483
1588
  <style>
1484
- :root {
1485
- --color-passed: #22c55e;
1486
- --color-failed: #ef4444;
1487
- --color-skipped: #f59e0b;
1488
- --color-info: #3b82f6;
1489
- --bg-primary: #ffffff;
1490
- --bg-secondary: #f8fafc;
1491
- --text-primary: #1e293b;
1492
- --text-secondary: #64748b;
1493
- --border-color: #e2e8f0;
1494
- }
1495
- * { margin: 0; padding: 0; box-sizing: border-box; }
1496
1589
  body {
1497
1590
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1498
- background: var(--bg-secondary);
1499
- color: var(--text-primary);
1500
- line-height: 1.6;
1501
- }
1502
- .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
1503
- header {
1504
- background: white;
1505
- border-bottom: 1px solid var(--border-color);
1506
- padding: 20px;
1507
- margin-bottom: 20px;
1508
- border-radius: 8px;
1509
- }
1510
- header h1 { font-size: 24px; font-weight: 600; }
1511
- header .meta { color: var(--text-secondary); font-size: 14px; margin-top: 4px; }
1512
- .summary { display: flex; gap: 16px; margin: 20px 0; flex-wrap: wrap; }
1513
- .card {
1514
- padding: 16px 24px; border-radius: 8px; color: white;
1515
- font-weight: 600; min-width: 120px; text-align: center;
1516
- }
1517
- .card.passed { background: var(--color-passed); }
1518
- .card.failed { background: var(--color-failed); }
1519
- .card.skipped { background: var(--color-skipped); }
1520
- .card.total { background: var(--color-info); }
1521
- .card .card-value { font-size: 28px; }
1522
- .card .card-label { font-size: 13px; opacity: 0.9; }
1523
- .pass-rate {
1524
- font-size: 48px; font-weight: 700; text-align: center; margin: 20px 0;
1525
- color: ${parseFloat(passRate) >= 80 ? "var(--color-passed)" : parseFloat(passRate) >= 50 ? "var(--color-skipped)" : "var(--color-failed)"};
1526
- }
1527
- .pass-rate-label { text-align: center; color: var(--text-secondary); margin-bottom: 20px; }
1528
- .by-type { display: flex; gap: 12px; flex-wrap: wrap; margin: 20px 0; }
1529
- .type-card {
1530
- background: white; border: 1px solid var(--border-color); border-radius: 8px;
1531
- padding: 12px 16px; min-width: 180px;
1532
- }
1533
- .type-name { font-weight: 600; text-transform: capitalize; margin-bottom: 4px; }
1534
- .type-stats { font-size: 13px; }
1535
- .type-stats span { margin-right: 8px; }
1536
- .type-stats .passed { color: var(--color-passed); }
1537
- .type-stats .failed { color: var(--color-failed); }
1538
- .type-stats .skipped { color: var(--color-skipped); }
1539
- .type-rate { font-size: 20px; font-weight: 700; margin-top: 4px; }
1540
- .suite {
1541
- background: white; border: 1px solid var(--border-color);
1542
- border-radius: 8px; margin-bottom: 12px; overflow: hidden;
1543
- }
1544
- .suite-header {
1545
- padding: 12px 16px; display: flex; justify-content: space-between;
1546
- align-items: center; cursor: pointer; user-select: none;
1547
- }
1548
- .suite-header:hover { background: var(--bg-secondary); }
1549
- .suite-header div { display: flex; align-items: center; gap: 8px; }
1550
- .suite.collapsed .suite-body { display: none; }
1551
- .suite-file { color: var(--text-secondary); font-size: 12px; }
1552
- .toggle-icon { font-size: 12px; color: var(--text-secondary); transition: transform 0.2s; }
1553
- .suite.collapsed .toggle-icon { transform: rotate(-90deg); }
1554
- .suite-body { border-top: 1px solid var(--border-color); padding: 8px 16px; }
1555
- .test-item {
1556
- padding: 8px 0; display: flex; align-items: center; gap: 8px;
1557
- }
1558
- .test-item + .test-item { border-top: 1px solid var(--border-color); }
1559
- .status-dot {
1560
- width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
1561
- }
1562
- .status-dot.passed { background: var(--color-passed); }
1563
- .status-dot.failed { background: var(--color-failed); }
1564
- .status-dot.skipped { background: var(--color-skipped); }
1565
- .status-dot.pending { background: var(--text-secondary); }
1566
- .test-name { flex: 1; }
1567
- .duration { color: var(--text-secondary); font-size: 13px; }
1568
- .retries { color: var(--color-skipped); font-size: 12px; }
1569
- .error-message {
1570
- background: #fef2f2; color: #991b1b; padding: 12px;
1571
- border-radius: 4px; font-family: 'Fira Code', monospace;
1572
- font-size: 13px; margin: 8px 0 8px 16px; white-space: pre-wrap;
1573
- word-break: break-all;
1574
- }
1575
- footer {
1576
- text-align: center; padding: 20px; color: var(--text-secondary);
1577
- font-size: 13px; margin-top: 40px;
1578
- }
1591
+ max-width: 960px; margin: 0 auto; padding: 20px;
1592
+ background: #f8fafc; color: #1e293b; line-height: 1.6;
1593
+ }
1594
+ h1 { border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
1595
+ h2 { margin-top: 24px; color: #1e40af; }
1596
+ h3 { margin-top: 16px; color: #334155; }
1597
+ h4 { margin-top: 12px; color: #475569; }
1598
+ blockquote { color: #64748b; border-left: 3px solid #cbd5e1; padding-left: 12px; margin: 8px 0; }
1599
+ table { border-collapse: collapse; width: 100%; margin: 12px 0; }
1600
+ td, th { border: 1px solid #e2e8f0; padding: 8px 12px; text-align: left; }
1601
+ tr:nth-child(even) { background: #f1f5f9; }
1602
+ code { background: #e2e8f0; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
1603
+ pre { background: #1e293b; color: #e2e8f0; padding: 16px; border-radius: 8px; overflow-x: auto; }
1604
+ pre code { background: none; padding: 0; }
1605
+ hr { border: none; border-top: 1px solid #e2e8f0; margin: 24px 0; }
1606
+ details { margin: 8px 0; }
1607
+ summary { cursor: pointer; color: #ef4444; font-weight: 600; }
1608
+ strong { color: #1e293b; }
1609
+ .pass-rate { font-size: 48px; font-weight: 700; text-align: center; margin: 20px 0;
1610
+ color: ${parseFloat(passRate) >= 80 ? "#22c55e" : parseFloat(passRate) >= 50 ? "#f59e0b" : "#ef4444"};
1611
+ }
1612
+ .pass-rate-label { text-align: center; color: #64748b; margin-bottom: 20px; }
1579
1613
  </style>
1580
1614
  </head>
1581
1615
  <body>
1582
- <div class="container">
1583
- <header>
1584
- <h1>QAT \u6D4B\u8BD5\u62A5\u544A</h1>
1585
- <div class="meta">\u751F\u6210\u65F6\u95F4: ${formatTimestamp(data.timestamp)} | \u603B\u8017\u65F6: ${formatDuration(data.duration)}</div>
1586
- </header>
1587
-
1588
- <div class="pass-rate">${passRate}%</div>
1589
- <div class="pass-rate-label">\u6D4B\u8BD5\u901A\u8FC7\u7387</div>
1590
-
1591
- <div class="summary">
1592
- <div class="card total">
1593
- <div class="card-value">${data.summary.total}</div>
1594
- <div class="card-label">\u603B\u8BA1</div>
1595
- </div>
1596
- <div class="card passed">
1597
- <div class="card-value">${data.summary.passed}</div>
1598
- <div class="card-label">\u901A\u8FC7</div>
1599
- </div>
1600
- <div class="card failed">
1601
- <div class="card-value">${data.summary.failed}</div>
1602
- <div class="card-label">\u5931\u8D25</div>
1603
- </div>
1604
- <div class="card skipped">
1605
- <div class="card-value">${data.summary.skipped}</div>
1606
- <div class="card-label">\u8DF3\u8FC7</div>
1607
- </div>
1608
- </div>
1609
-
1610
- ${Object.keys(data.byType).length > 0 ? `<h2 style="margin-top:30px;margin-bottom:10px;">\u6309\u7C7B\u578B\u7EDF\u8BA1</h2><div class="by-type">${byTypeHTML}</div>` : ""}
1611
-
1612
- <h2 style="margin-top:30px;margin-bottom:10px;">\u6D4B\u8BD5\u8BE6\u60C5</h2>
1613
- ${suitesHTML || '<p style="color:var(--text-secondary)">\u6682\u65E0\u6D4B\u8BD5\u7ED3\u679C</p>'}
1614
-
1615
- <footer>\u7531 QAT \u81EA\u52A8\u5316\u6D4B\u8BD5\u5DE5\u5177\u751F\u6210</footer>
1616
- </div>
1616
+ <div class="pass-rate">${passRate}%</div>
1617
+ <div class="pass-rate-label">\u6D4B\u8BD5\u901A\u8FC7\u7387</div>
1618
+ ${html}
1617
1619
  </body>
1618
1620
  </html>`;
1619
1621
  }
1620
- function writeReportToDisk(data, outputDir) {
1621
- const html = generateHTMLReport(data);
1622
- const dir = path4.resolve(outputDir);
1623
- if (!fs5.existsSync(dir)) {
1624
- fs5.mkdirSync(dir, { recursive: true });
1625
- }
1626
- const indexPath = path4.join(dir, "index.html");
1627
- fs5.writeFileSync(indexPath, html, "utf-8");
1628
- return indexPath;
1629
- }
1630
1622
 
1631
1623
  // src/runners/vitest-runner.ts
1632
1624
  import { execFile } from "child_process";
@@ -1701,34 +1693,109 @@ function buildVitestArgs(options) {
1701
1693
  return args;
1702
1694
  }
1703
1695
  async function execVitest(args) {
1696
+ const os = await import("os");
1697
+ const fs9 = await import("fs");
1698
+ const tmpFile = path5.join(os.tmpdir(), `qat-vitest-result-${Date.now()}.json`);
1699
+ const argsWithOutput = [...args, "--outputFile", tmpFile];
1704
1700
  return new Promise((resolve, reject) => {
1705
1701
  const npx = process.platform === "win32" ? "npx.cmd" : "npx";
1706
- const child = execFile(npx, args, {
1702
+ const child = execFile(npx, argsWithOutput, {
1707
1703
  cwd: process.cwd(),
1708
- env: { ...process.env, FORCE_COLOR: "0" },
1704
+ env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
1709
1705
  maxBuffer: 50 * 1024 * 1024,
1710
- // 50MB
1711
1706
  shell: true
1712
1707
  }, (error, stdout, stderr) => {
1713
- const output = stdout || stderr || "";
1708
+ const rawOutput = stdout || stderr || "";
1709
+ let jsonResult = null;
1714
1710
  try {
1715
- const parsed = parseVitestJSONOutput(output);
1716
- resolve(parsed);
1711
+ if (fs9.existsSync(tmpFile)) {
1712
+ jsonResult = fs9.readFileSync(tmpFile, "utf-8");
1713
+ fs9.unlinkSync(tmpFile);
1714
+ }
1717
1715
  } catch {
1718
- if (output) {
1719
- resolve(parseVitestTextOutput(output, !!error));
1716
+ }
1717
+ if (jsonResult) {
1718
+ try {
1719
+ const parsed = parseVitestJSONResult(jsonResult);
1720
+ resolve({ ...parsed, rawOutput });
1721
+ return;
1722
+ } catch {
1723
+ }
1724
+ }
1725
+ try {
1726
+ const parsed = parseVitestJSONOutput(rawOutput);
1727
+ resolve({ ...parsed, rawOutput });
1728
+ } catch {
1729
+ if (rawOutput) {
1730
+ resolve({ ...parseVitestTextOutput(rawOutput, !!error), rawOutput });
1720
1731
  } else if (error && error.message.includes("ENOENT")) {
1721
1732
  reject(new Error("\u672A\u627E\u5230 vitest\uFF0C\u8BF7\u786E\u4FDD\u5DF2\u5B89\u88C5: npm install -D vitest"));
1722
1733
  } else {
1723
- resolve({ success: !error, suites: [] });
1734
+ resolve({ success: !error, suites: [], rawOutput });
1724
1735
  }
1725
1736
  }
1726
1737
  });
1727
1738
  child.on("error", (err) => {
1739
+ try {
1740
+ fs9.unlinkSync(tmpFile);
1741
+ } catch {
1742
+ }
1728
1743
  reject(new Error(`Vitest \u6267\u884C\u5931\u8D25: ${err.message}`));
1729
1744
  });
1730
1745
  });
1731
1746
  }
1747
+ function parseVitestJSONResult(jsonStr) {
1748
+ const data = JSON.parse(jsonStr);
1749
+ const suites = [];
1750
+ if (data.testResults && Array.isArray(data.testResults)) {
1751
+ for (const fileResult of data.testResults) {
1752
+ const suiteTests = [];
1753
+ const assertions = fileResult.assertionResults || fileResult.tests || [];
1754
+ for (const assertion of assertions) {
1755
+ suiteTests.push({
1756
+ name: assertion.title || assertion.fullName || assertion.name || "unknown",
1757
+ file: fileResult.name || "unknown",
1758
+ status: mapVitestStatus(assertion.status),
1759
+ duration: assertion.duration || 0,
1760
+ error: assertion.failureMessages?.length ? { message: assertion.failureMessages[0] } : assertion.failureMessage ? { message: assertion.failureMessage } : void 0,
1761
+ retries: 0
1762
+ });
1763
+ }
1764
+ if (suiteTests.length === 0 && fileResult.numPassingTests !== void 0) {
1765
+ const counts = [
1766
+ { n: fileResult.numPassingTests || 0, s: "passed" },
1767
+ { n: fileResult.numFailingTests || 0, s: "failed" },
1768
+ { n: fileResult.numPendingTests || 0, s: "skipped" }
1769
+ ];
1770
+ for (const { n, s } of counts) {
1771
+ for (let i = 0; i < n; i++) {
1772
+ suiteTests.push({
1773
+ name: `${s} test ${i + 1}`,
1774
+ file: fileResult.name || "unknown",
1775
+ status: s,
1776
+ duration: 0,
1777
+ retries: 0
1778
+ });
1779
+ }
1780
+ }
1781
+ }
1782
+ suites.push({
1783
+ name: path5.basename(fileResult.name || "unknown"),
1784
+ file: fileResult.name || "unknown",
1785
+ type: "unit",
1786
+ status: mapVitestStatus(fileResult.status),
1787
+ duration: fileResult.duration || 0,
1788
+ tests: suiteTests
1789
+ });
1790
+ }
1791
+ }
1792
+ let coverage;
1793
+ if (data.coverageMap) {
1794
+ coverage = extractCoverage(data.coverageMap);
1795
+ }
1796
+ const success = data.success !== false && data.numFailedTests === void 0 ? suites.every((s) => s.status !== "failed") : (data.numFailedTests || 0) === 0;
1797
+ return { success, suites, coverage };
1798
+ }
1732
1799
  function parseVitestJSONOutput(output) {
1733
1800
  const jsonMatch = output.match(/\{[\s\S]*"testResults"[\s\S]*\}/);
1734
1801
  if (!jsonMatch) {